-
RSpec For Ops Part 3: Test driven design with rspec-puppet
This is the third part in a series of blog posts on RSpec for Ops. See the first post here and the second post here.
Designing for Testability
Once you get more comfortable with TDD for Puppet, you’ll start to realize that there’s a particular way to design your code to make it easier to test. I often hear complaints from newcomers to TDD to the effect of “what’s the point of TDD if I have to change how I write my code just for these tests?” This is a very good question. To answer this, let’s take a look at what makes code easier to test.
Interfaces
As I mentioned in my last post, the key to writing good tests is to focus on your module’s interfaces. Your module should have a well defined interface. When I say interface, I’m talking about an Application Programming Interface (or API) which, in Puppet, is created using parameters. Your goal should be to validate your module’s interface, ensuring that all branch conditions are covered by your tests. It’s much easier to test code that has an interface rather than trying to test conditions that are hardcoded inside the module. Let’s look at an example:
class ssh { $package_name = $osfamily ? { 'gentoo' => 'openssh', default => 'openssh-server' } package { $package_name: ensure => installed } }
The example above installs an ssh server. We want to support more than one platform, so we’ve used a selector to determine the package name based on the
osfamily
fact. Let’s see what the test looks like for this:describe 'ssh' do context 'on gentoo' do let(:facts) { { :osfamily => 'gentoo' } } it { is_expected.to contain_package 'openssh' } end context 'on redhat' do let(:facts) { { :osfamily => 'redhat' } } it { is_expected.to contain_package 'openssh-server' } end end
This test looks simple enough, right? It’s testing all of our branch conditions and gets pretty good coverage. What happens when you want to add a new platform? What happens when the package name changes? You’ll have to write new tests to ensure that your branch conditions are covered. Let’s see what this looks like with an interface:
class ssh ( $package_name = $ssh::params::package_name, ) inherits ssh::params { package { $package_name: ensure => installed } }
This example uses the
$package_name
parameter to set the package name and gets its default value from theparams
class. Let’s see what the test would look like for this class:describe 'ssh' do let(:params) { { :package_name => 'openssh-server' } } it { is_expected.to contain_package 'openssh-server' } end
This test validates our interface and makes sure that the value we set at
$package_name
is passed to the package resource as we expected. If some part of the code changes the way this parameter is passed to the package resource, the tests will fail. To be sure, you should also validate your branching logic from yourparams
class, but focusing on the interface here has forced us to redesign our code in a way that has some nice corollary benefits. This class is no longer dependent on embedded logic, so it is more resilient to unforeseen use cases. Our tests needs to know even less about our implementation code, which makes them more resilient and easier to maintain.Composition and Single Responsibility
Composition is a practice where you combine discrete bits of code to achieve a desired behavior. Using composition along with classes that have a single responsibility, you can achieve complex behaviors that are easy to test, read, and extend. To illustrate, let’s take a look at a bit of code with many responsibilities.
class ssh { package { 'openssh-server': ensure => installed, } file { '/etc/ssh/sshd_config': ensure => file, content => template('ssh/sshd_config.erb'), require => Package['openssh-server'], notify => Service['sshd'], } service { 'sshd': ensure => running, enabled => true, } }
This example does three things: it installs a package, places a configuration file, and ensures a service. This class has three separate responsibilities. This means it has three behaviors that we’d need to test. Let’s look at the tests for this module.
describe 'ssh' do it { is_expected.to contain_package 'openssh-server' } it 'should configure ssh' do is_expected.to contain_file('/etc/ssh/sshd_config').that_requires('Package[openssh-server]') is_expected.to contain_file('/etc/ssh/sshd_config').that_notifies('Service[sshd]') end it 'should start sshd' do is_expected.to contain_service('sshd').with { :ensure => 'running', :enabled => true } end end
This is not such a burden right now, but once this module gets big enough, it’ll be much easier to test these behaviors individually rather than together. In fact, there’s an established pattern to handle this exact example: the package, file, service pattern. Let’s refactor this module to use the package, file, service pattern and see how the tests look.
class ssh { include ssh::install include ssh::config include ssh::service Class['ssh::install'] -> Class['ssh::config'] ~> Class['ssh::service'] } class ssh::install { package { 'openssh-server': ensure => installed } } class ssh::config { file { '/etc/ssh/sshd_config': ensure => file, content => template('ssh/sshd_config.erb'), } } class ssh::service { service { 'sshd': ensure => running, enabled => true } }
Each of these classes has a singular responsibility. The
ssh
class is responsible for including the subclasses that make up the desired behavior for the module, and ensuring that the resource ordering is correct. Thessh::install
class is responsible for installing the ‘openssh-server’ package. Thessh::config
class is responsible for configuring thesshd
service. Finally, thessh::service
class is responsible for managing thesshd
service.describe 'ssh' do it { is_expected.to contain_class('ssh::install').that_comes_before('Class[ssh::config]') } it { is_expected.to contain_class('ssh::config').that_notifies('Class[ssh::service]') } it { is_expected.to contain_class('ssh::service') } end describe 'ssh::install' do it { is_expected.to contain_package('openssh-server') } end describe 'ssh::config' do it { is_expected.to contain_file('/etc/ssh/sshd_config') } end describe 'ssh::service' do is_expected.to contain_service('sshd').with({ :ensure => 'running', :enabled => true }) end
Writing classes with a single responsibility makes writing tests easier because you can focus on a single behavior at a time. You can group your tests based on the behaviors they’re testing, and you can test different conditions that may affect your code’s behavior discretely. Once you’ve refactored this code into discrete classes with a single responsibility, you can achieve the same overall behavior with composition.
Designing for Testability is Good Overall Design
Design for testability just so happens to be good overall design. These design principles include programming to an interface, composition, and single responsibility. If you encounter a class that’s hard to test, think about why it’s hard. Chances are you’ve identified a good opportunity to redesign your code because test driven development fleshes out hidden design flaws. At first it may seem like you’re massaging your code to fit your testing strategy but, in reality, you’re fixing design flaws that make it harder to read, maintain, and change your code.
Tests should be designed to be largely independent of your code’s internals. If you find yourself battling fragile tests, you’ve probably coupled your tests to your code too tightly. Take a look at your test code, as well as your implementation code, and try and identify missing or poorly defined interfaces. Think about the assumptions you’re making in your code. Make sure you’re not hard coding those assumptions into your tests to make up for missing interfaces.
TDD may seem counterintuitive at first, but that will quickly pass. The benefits of TDD are proven, and become apparent rather quickly. After a while you’ll notice yourself writing code that’s simpler, more modular, more readily able to handle unforeseen use cases, and easier to maintain.
-
RSpec For Ops Part 2: Diving in with rspec-puppet
This is the second part in a series of blog posts on RSpec for Ops. See the first post here.
Now that we’ve got a good understanding of some of the RSpec primitives, we’ll dive into how these can be used when testing infrastructure. I’m going to use
rspec-puppet
for my examples here. In a forthcoming post I’ll useserverspec
for a more agnostic approach. If you’re not a Puppet user, don’t worry, the two main Infracode testing tools (Beaker and Kitchen) both make heavy use of Serverspec.There are two general types of tests that you’ll write as an Infracoder: acceptance and unit tests. We’ll use
rspec-puppet
for unit tests.Unit tests
Unit tests are written to test a specific bit of code. With
rspec-puppet
we test our Puppet classes, defined types, and resources. We’ll userspec-puppet
to define a specification for a Puppet class. This class will install a package, place a configuration file, and start a service. Because we’re using test driven development, we’ll write our tests before we write the code. Let’s get started:require 'spec_helper' describe 'ntp' do it { is_expected.to compile } end
Here we have written a test for the
ntp
class. We have a single example with the expectation that the catalog will compile with thentp
class included. There’s somerspec-puppet
magic going on here, so let’s delve into this. First, you’ll notice that I’m not using an explicit subject here, we’re relying on the implicit subject using the first argument passed to thedescribe
method. Therspec-puppet
helpers assume the first argument to thedescribe
method is the name of a Puppet class. It will automatically generate a manifest withinclude ntp
as the content and will use that manifest to compile a catalog. The resulting catalog object then gets stored as thesubject
for use in our examples. This test has a single expectation. Using thecompile
matcher, we set the expectation that thecatalog
will successfully compile. When we run this, we’ll get an error:ntp should compile into a catalogue without dependency cycles (FAILED - 1) Failures: 1) ntp should compile into a catalogue without dependency cycles Failure/Error: it { is_expected.to compile } error during compilation: Evaluation Error: Error while evaluating a Function Call, Could not find class ::ntp for mbp.danzil.io at line 1:1 on node mbp.danzil.io # ./spec/classes/ntp_spec.rb:4:in `block (2 levels) in <top (required)>' Finished in 0.17856 seconds (files took 0.69616 seconds to load) 1 example, 1 failure Failed examples: rspec ./spec/classes/ntp_spec.rb:4 # ntp should compile into a catalogue without dependency cycles
This error is expected, since we never actually wrote the code to implement the
ntp
class. Let’s do that now:class ntp { }
The goal is to write the minimum amount of code to make the tests pass. All our tests are looking for is that the
ntp
class exists and that it compiles. The code above is all we need to meet that requirement. If we run the tests again, we’ll see:ntp should compile into a catalogue without dependency cycles Finished in 0.18003 seconds (files took 0.69723 seconds to load) 1 example, 0 failures
Now, this class doesn’t do anything, so it’s not particularly useful. Let’s install the
ntp
package:require 'spec_helper' describe 'ntp' do it { is_expected.to compile } it { is_expected.to contain_package('ntpd') } end
We’ve added a new example to our
ntp
group with the expectation that the catalog will contain thentpd
package. We’re using thecontain_package
matcher to assert this expectation. When we run our new tests we’ll have a new failure:ntp should compile into a catalogue without dependency cycles should contain Package[ntpd] (FAILED - 1) Failures: 1) ntp should contain Package[ntpd] Failure/Error: it { is_expected.to contain_package('ntpd') } expected that the catalogue would contain Package[ntpd] # ./spec/classes/ntp_spec.rb:5:in `block (2 levels) in <top (required)>' Finished in 1.47 seconds (files took 0.79995 seconds to load) 2 examples, 1 failure Failed examples: rspec ./spec/classes/ntp_spec.rb:5 # ntp should contain Package[ntpd]
The output is complaining that the catalog is missing the expected
ntpd
package. Let’s add the code to make that test pass:class ntp { package { 'ntpd': } }
Now our tests should pass:
ntp should compile into a catalogue without dependency cycles should contain Package[ntpd] Finished in 1.41 seconds (files took 0.77791 seconds to load) 2 examples, 0 failures
You’ll continue with this until you have a functioning module. I’m going to skip ahead a bit so we can move on to more interesting things. Here’s the rest of the spec I’m going to write for this module:
require 'spec_helper' describe 'ntp' do it { is_expected.to compile } it { is_expected.to contain_package('ntpd') } it 'should contain the ntp configuration file' do is_expected.to contain_file('/etc/ntpd.conf') is_expected.to contain_file('/etc/ntpd.conf').that_requires('Package[ntpd]') is_expected.to contain_file('/etc/ntpd.conf').that_notifies('Service[ntpd]') end it { is_expected.to contain_service('ntpd') } end
When I run this test, we’ll see the expected failures:
ntp should compile into a catalogue without dependency cycles should contain Package[ntpd] should contain the ntp configuration file (FAILED - 1) should contain Service[ntpd] (FAILED - 2) Failures: 1) ntp should contain the ntp configuration file Failure/Error: is_expected.to contain_file('/etc/ntpd.conf') expected that the catalogue would contain File[/etc/ntpd.conf] # ./spec/classes/ntp_spec.rb:8:in `block (2 levels) in <top (required)>' 2) ntp should contain Service[ntpd] Failure/Error: it { is_expected.to contain_service('ntpd') } expected that the catalogue would contain Service[ntpd] # ./spec/classes/ntp_spec.rb:13:in `block (2 levels) in <top (required)>' Finished in 1.39 seconds (files took 0.66577 seconds to load) 4 examples, 2 failures Failed examples: rspec ./spec/classes/ntp_spec.rb:7 # ntp should contain the ntp configuration file rspec ./spec/classes/ntp_spec.rb:13 # ntp should contain Service[ntpd]
I’ll implement those features in the code:
class ntp { package { 'ntpd': } file { '/etc/ntpd.conf': ensure => file, content => file('ntp/ntpd.conf'), require => Package['ntpd'], notify => Service['ntpd'], } service { 'ntpd': ensure => running, enable => true, } }
When I run my tests, all of my examples pass:
ntp should compile into a catalogue without dependency cycles should contain Package[ntpd] should contain the ntp configuration file should contain Service[ntpd] Finished in 2.03 seconds (files took 0.73826 seconds to load) 4 examples, 0 failures
Great! We have a functioning Puppet module, but not a particularly useful one. This is an admittedly contrived example. It only works on platforms that have a package named
ntpd
and call that servicentpd
and look for a configuration file at/etc/ntpd.conf
. Let’s make this code a little more useful by adding some parameters:require 'spec_helper' describe 'ntp' do let(:params) do { :package_name => 'ntpd', :config_file => '/etc/ntpd.conf', :service_name => 'ntpd' } end it { is_expected.to compile } it { is_expected.to contain_package('ntpd') } it 'should contain the ntp configuration file' do is_expected.to contain_file('/etc/ntpd.conf') is_expected.to contain_file('/etc/ntpd.conf').that_requires('Package[ntpd]') is_expected.to contain_file('/etc/ntpd.conf').that_notifies('Service[ntpd]') end it { is_expected.to contain_service('ntpd') } end
The test above uses the
let
method to declareparams
.rspec-puppet
looks atparams
for a hash of parameters to pass to the Puppet class. Let’s run this test:ntp should compile into a catalogue without dependency cycles (FAILED - 1) should contain Package[ntpd] (FAILED - 2) should contain the ntp configuration file (FAILED - 3) should contain Service[ntpd] (FAILED - 4) Failures: 1) ntp should compile into a catalogue without dependency cycles Failure/Error: it { is_expected.to compile } error during compilation: Evaluation Error: Error while evaluating a Resource Statement, Class[Ntp]: has no parameter named 'package_name' has no parameter named 'config_file' has no parameter named 'service_name' at line 1:1 on node mbp.danzil.io # ./spec/classes/ntp_spec.rb:10:in `block (2 levels) in <top (required)>' 2) ntp should contain Package[ntpd] Failure/Error: it { is_expected.to contain_package('ntpd') } Puppet::PreformattedError: Evaluation Error: Error while evaluating a Resource Statement, Class[Ntp]: has no parameter named 'package_name' has no parameter named 'config_file' has no parameter named 'service_name' at line 1:1 on node mbp.danzil.io 3) ntp should contain the ntp configuration file Failure/Error: is_expected.to contain_file('/etc/ntpd.conf') Puppet::PreformattedError: Evaluation Error: Error while evaluating a Resource Statement, Class[Ntp]: has no parameter named 'package_name' has no parameter named 'config_file' has no parameter named 'service_name' at line 1:1 on node mbp.danzil.io 4) ntp should contain Service[ntpd] Failure/Error: it { is_expected.to contain_service('ntpd') } Puppet::PreformattedError: Evaluation Error: Error while evaluating a Resource Statement, Class[Ntp]: has no parameter named 'package_name' has no parameter named 'config_file' has no parameter named 'service_name' at line 1:1 on node mbp.danzil.io Finished in 0.18966 seconds (files took 0.69537 seconds to load) 4 examples, 4 failures Failed examples: rspec ./spec/classes/ntp_spec.rb:10 # ntp should compile into a catalogue without dependency cycles rspec ./spec/classes/ntp_spec.rb:11 # ntp should contain Package[ntpd] rspec ./spec/classes/ntp_spec.rb:13 # ntp should contain the ntp configuration file rspec ./spec/classes/ntp_spec.rb:19 # ntp should contain Service[ntpd]
I’ve truncated the output here for clarity. You’ll see that everything blew up because I’m trying to pass parameters to a class that doesn’t accept parameters! Let’s add those parameters to my Puppet class:
class ntp ( $package_name = 'ntpd', $config_file = '/etc/ntpd.conf', $service_name = 'ntpd', ){ package { $package_name: } file { $config_file: ensure => file, content => file('ntp/ntpd.conf'), require => Package[$package_name], notify => Service[$service_name], } service { $service_name: ensure => running, enable => true, } }
Now when we run our tests, we’ll see a much greener (and more familiar) output:
ntp should compile into a catalogue without dependency cycles should contain Package[ntpd] should contain the ntp configuration file should contain Service[ntpd] Finished in 1.76 seconds (files took 0.66553 seconds to load) 4 examples, 0 failures
Interfaces are Key
We’ve just refactored this code to accept parameters. The most important testing practice (IMHO) is to focus on the interface, not the implementation. By adding parameters to this class, we’ve created an interface. Our parameters are our module’s API. Well written tests should validate our interfaces and ensure that the data we’re passing to our interface is making it to our resources the way we expect it to. When we focus on the implementation we end up just rewriting our code in another language, focusing on the interface allows us to focus on the bits of our code that we really care about.
Now that we have some parameters, we can start testing different contexts. Contexts are helpful when testing your module’s interface under different conditions. We’ll want to validate our interface in a number of different ways. I’m going to restructure our tests to focus on the interface:
require 'spec_helper' describe 'ntp' do context 'with the default parameters' do it { is_expected.to compile } it { is_expected.to contain_package('ntpd') } it 'should contain the ntp configuration file' do is_expected.to contain_file('/etc/ntpd.conf') is_expected.to contain_file('/etc/ntpd.conf').that_requires('Package[ntpd]') is_expected.to contain_file('/etc/ntpd.conf').that_notifies('Service[ntpd]') end it { is_expected.to contain_service('ntpd') } end context 'when specifying the package_name parameter' do let(:params) { { :package_name => 'ntp' } } it { is_expected.to contain_package('ntp') } it { is_expected.to contain_file('/etc/ntpd.conf').that_requires('Package[ntp]') } end context 'when specifying the config_file parameter' do let(:params) { { :config_file => '/etc/ntp.conf' } } it { is_expected.to contain_file('/etc/ntp.conf') } end context 'when specifying the service_name parameter' do let(:params) { { :service_name => 'ntp' } } it { is_expected.to contain_service('ntp') } it { is_expected.to contain_file('/etc/ntpd.conf').that_notifies('Service[ntp]') } end end
As you can see, I’ve added contexts for each of my individual parameters, as well as a context that covers the default parameter values. I am testing to make sure that the parameter values are being consumed correctly in my code. These tests will already pass since we’re not implementing any new features here just improving the tests.
ntp with the default parameters should compile into a catalogue without dependency cycles should contain Package[ntpd] should contain the ntp configuration file should contain Service[ntpd] when specifying the package_name parameter should contain Package[ntp] should contain File[/etc/ntpd.conf] that requires Package[ntp] when specifying the config_file parameter should contain File[/etc/ntp.conf] when specifying the service_name parameter should contain Service[ntp] should contain File[/etc/ntpd.conf] that notifies Service[ntp] Finished in 2.11 seconds (files took 0.78148 seconds to load) 9 examples, 0 failures
Remember, the goal is not to rewrite your Puppet code in RSpec, it’s to ensure that your code is behaving the way you expect it to.
Stay Tuned
In the next post in this series, I’ll dive a little deeper into module development and
rspec-puppet
best practices. I’ll also follow up with a post where I’ll useserverspec
to write some acceptance tests for this example code.
-
Changes
There have been a few big changes in my life over the past few months. In March we bought a house, which has been an interesting learning experience for me. But I promised I won’t talk too much about my homeownership adventures, so that’s not what this post is about. This post is about another adventure, a new job.
In February of 2014 I moved from New Mexico back to my home state of Massachusetts. My husband had just been accepted to Boston University Law School, and I had been dying to get back to my roots. I had been living in New Mexico for almost a decade, which turned out to be much longer than I originally planned. I took a job at Constant Contact as a Systems Engineer and Puppet Evangelist.
The past two-ish years at Constant Contact have been great. This was my first experience in a real web-scale SaaS shop. SaaS is really the holy-grail of tech ops, and I was quite lucky to land a job like this one. Constant Contact has taught me a lot about myself, about scaling, about succeeding in an enterprise, and about what DevOps really means in practice. I’ve picked up a lot of hard skills, and even more soft skills. But, all good things must come to an end. This coming Friday will be my last day at Constant Contact. It is bittersweet, as leaving a job often is. I’ve made some great friends, and grown quite a bit as a professional during my tenure.
I’m going to be joining the Professional Services team at Kovarus. Kovarus is based in Silicon Valley, but I’ll be working remotely from Boston. My focus as a consultant will be on Puppet and continuous delivery. I’m really looking forward to joining this team and being able to share my experiences with others. If you know me, or follow me, you probably know that I’m really passionate about teaching and sharing. I think this will be a great way to engage that passion in combination with my other, more engineering oriented skills. Cue the consultant jokes…you know you want to…
So stay tuned for updates on the transition. I’ll probably be writing about my adventures as a remote employee and learning how to work from home full time. I’ll still be active in the community, hopefully even more so.
Also, if you’re interested in some consulting, let me know :)
-
Welcome to Blog Month!
Today marks the beginning of a new experiment for me. I, along few others, will be taking part in blog month. Blog month is a commitment to write 30 posts over the course of a month. You can blog about anything you want, it doesn’t have to be technical in nature. The goal is to get into the habit of writing regularly.
I first heard about this idea from Rob Nelson (rnelson0 on the interwebz). A few months ago, Rob tossed around the idea of a mid-year blog month in the #voxpupuli channel on Freenode. The traditional blog month is in November (to coincide with NaNoWriMo), but that often clashes with the holidays, so it’s not particularly convenient for some of us. At the time, May sounded great. Since then, I’ve taken a new job (see tomorrow’s post), bought a house, and am slated to speak and attend a couple conferences this month. I’ve basically come to the realization that there’s no good time for this, so I’m just going to dig in.
I have a few posts in mind. I want to finish up a couple blog series that I started and never finished (Seven Habits, and RSpec for Ops). I’m also going to start fleshing out some topics I’ll be speaking on starting this month. I’d love to get some feedback on these things, so please, don’t hold back. If there’s anything you might be interested in reading about, please let me know!
I’ll be announcing these posts on Twitter. Rob has put together a Twitter list tracking those of us participating in blog month. You can find that list here. If you’re interested in participating, or just want to learn more about the genesis of this idea, I highly encourage you to read Rob’s post about it back in March. You can also track the hashtags #blogmonth or #vDM30in30.
I’m really looking forward to writing and reading some great content this month! Good luck to all of those participating! Happy blogging!
-
RSpec For Ops Part 1: Essentials
RSpec For Ops
I’ve noticed a lot of interest in RSpec from ops folks lately, likely driven by infracode testing tools like Kitchen, Beaker, and Serverspec. I’ve also noticed a lot of ops folks using
rspec
without really understanding some of the fundamentals. This results in some suboptimalrspec
code, and generally lots of headaches for newcomers torspec
. This blog post is the first in a series attempting to outline some of the basics of test driven development withrspec
from the perspective of an Op :) I’ll start by outliningrspec
fundamentals, another post will follow with some (hopefully) relatable examples.RSpec Essentials
RSpec is a behavior driven development (BDD) framework for Ruby. BDD focuses on the behavior and expected outcomes of your code. RSpec provides a simple domain-specific language that employs natural-lanaguage constructs for describing tests. As a result, BDD code is often very easy to read with inputs and expectations being very easy to identify. RSpec offers several primitives to model the behavior of your code including groups, examples, contexts, expectations, and matchers.
Examples and Groups
One of the most fundamental primitives in
rspec
is the example. An example is the description of a behavior you expect your code to exhibit. Examples are declared using theit
method and are often grouped together logically inside an (very creatively named) example group. Examples are comprised of a subject and an expectation. Example groups are declared with thedescribe
method. Example groups make up your application’s specification; they often end up looking a lot like lists of acceptance criteria. Here’s a simple example:it { is_expected.to be true }
This code can be read as “it is expected to be true” and literally means that the subject of the test (we’ll go into more detail in a minute) should return
true
. I didn’t set asubject
in the example above, but we can do that with thelet
method:let(:subject) { true } it { is_expected.to be true }
The
let
method creates a memoized helper method set to the value passed in the block. A memoized helper method is basically anattr_reader
on an instance variable. Here’s what the abovelet
method effectively does under the hood:def subject @subject ||= true end
The
let
method creates a new method based on the name you passed it as an argument. The code passed to thelet
method’s block is then lazily evaluated and cached in the instance variable@subject
. Lazy evaluation means that the code associated with this method is not evaluated until it is called the first time. This is in contrast to eager evaluation where the code is evaluated as soon as it is bound to a variable. This allows you to set some state that persists inside an example group, this also means that you can only use thelet
method inside an example group. Let’s fix our example code:describe 'Truthiness' do let(:subject) { true } it { is_expected.to be true } end
Here we have an example group named
Truthiness
, we’ve set thesubject
totrue
, and we’ve created an example with the expectation that our subject istrue
. Let’s run this code and see what the output is:Truthiness should equal true Finished in 0.00107 seconds (files took 0.08412 seconds to load) 1 example, 0 failures
Expectations and Matchers
An example is just a list of behaviors expected of our subject. Our example has one expectation: that the subject should be
true
. Our example above could also be expressed using theexpect
method:describe 'Truthiness' do let(:subject) { true } it { expect(subject).to be true } end
This doesn’t read as smoothly as it did with the
is_expected
method, which is justexpect
with an implicitsubject
. We can make this example more readable by passing a descriptive title to theit
method:describe 'Truthiness' do let(:subject) { true } it 'should behave truthy' do expect(subject).to be true end end
Now this example is easier to read. We can see that our subject
should behave truthy
and we have a list of expectations that need to be met for our example to pass. Let’s add an expectation that our subject is notfalse
:describe 'Truthiness' do let(:subject) { true } it 'should behave truthy' do expect(subject).to be true expect(subject).not_to be false end end
These code examples are available on my GitHub here. The example above can be found in spec/truthiness_spec.rb
As you can see, examples can contain any number of expectations. You should use as many expectations as necessary to validate the behavior you’re testing in an example. One element of an expectation that we haven’t yet discussed is the matcher, which is really the heart of an expectation. Our example above only uses the
be
matcher but there are many others. Matchers describe the behavior you expect your subject to exhibit. In our example, we are using thebe
matcher to compare our subject’s identity with our matcher.There are lots of built-in matchers; common matchers include
be
,eq
,exist
,include
,match
,start_with
, andrespond_to
. RSpec has a robust matcher API that makes it very easy to write custom matchers. Infracode frameworks built on RSpec like Serverspec include a number of domain-specific matchers. Let’s take a look at some different kinds of matchers:describe 'Hello world!' do it 'should say hello' do expect(subject).to match /Hello world/ expect(subject).not_to match /Goodbye world/ expect(subject).to eq 'Hello world!' end end
In this example group we are using the
match
andeq
matchers. Notice I didn’t explicitly set asubject
here, we’re relying on the implicit subject set by the argument we passed to thedescribe
method. Thematch
matcher compares your subject with a regular expression, in this case/Hello world/
. You can set a negative expectation by using thenot_to
method on our expectation. In this example, we want to make sure that we’re saying “hello” so we want to make sure our subject does not match/Goodbye world/
. Finally, we’re using theeq
matcher to compare our subject with the stringHello world!
. Let’s see what the output of this test would look like:Hello world! should say hello Finished in 0.00203 seconds (files took 0.07591 seconds to load) 1 examples, 0 failures
Notice we did not use the
be
matcher in our example. This is because thebe
matcher tests object identity while theeq
matcher tests equality. Here’s what the output would look like if we used thebe
matcher:Hello world! should say hello (FAILED - 1) Failures: describe 'Hello world!' do 1) Hello world! should say hello Failure/Error: expect(subject).to be 'Hello world!' expected #<String:70291920962560> => "Hello world!" got #<String:70291943850520> => "Hello world!" Compared using equal?, which compares object identity, but expected and actual are not the same object. Use `expect(actual).to eq(expected)` if you don't care about object identity in this example. # ./spec/blog_examples/hello_world_spec.rb:6:in `block (2 levels) in <top (required)>' Finished in 0.01117 seconds (files took 0.07584 seconds to load) 1 examples, 1 failure Failed examples: rspec ./spec/blog_examples/hello_world_spec.rb:2 # Hello world! should say hello
As you can see, the
be
matcher actually compares the identity of each object instance, but this example uses two different instances ofString
. We’re really only interested in comparing the values of theString
instances so we use theeq
matcher.Contexts
RSpec offers two primary methods of grouping tests together. We’ve already looked at one way, by creating example groups with the
describe
method. The other way is to use thecontext
method. Thecontext
method is really just an alias for thedescribe
method. Thedescribe
method is generally used to create example groups that describe an object, while thecontext
method is used to create example groups that describe the state of an object that can vary. Here’s an example describing anArray
object with multiple contexts:describe Array do context 'with no elements' do let(:subject) { [] } it 'should be empty' do expect(subject.count).to eq 0 end end context 'with elements' do let(:subject) { ['foo', 'bar', 'baz'] } it 'should not be empty' do expect(subject.count).not_to eq 0 end end end
Here we’ve grouped our examples by the class of object we’re testing (
Array
), but we’ve segmented our examples based on the context we’re looking to describe. Eachcontext
introduces a new scope. Examples grouped bycontext
can have their own subjects, or they can inherit their subject from their parent. Here’s an example where we inherit our subject from our parent and test for different contexts:describe Hash do let(:baz_val) { nil } let(:subject) do { :foo => 'bar', :baz => baz_val } end it 'should have the foo key set to bar' do expect(subject[:foo]).to eq 'bar' end it 'should have the baz key set to nil' do expect(subject[:baz]).to be nil end context 'with baz_val set to qux' do let(:baz_val) { 'qux' } it 'should have the baz key set to qux' do expect(subject[:baz]).to eq 'qux' end end context 'with baz_val set to quux' do let(:baz_val) { 'quux' } it 'should have the baz key set to quux' do expect(subject[:baz]).to eq 'quux' end end end
Here we’re testing the same
Hash
object, but we’re testing that object under different contexts. We’re overriding thebaz_val
method inside ourcontext
blocks and ensuring that our object behaves the way we expect it to under different conditions. This allows us to test parts of our code that vary with context while avoiding duplicating our tests that focus on the parts of our code that remain stable. Segmenting our examples intocontext
blocks makes our test code and output more readable. Here’s the output of the above example:Hash should have the foo key set to bar should have the baz key set to nil with baz_val set to qux should have the baz key set to qux with baz_val set to quux should have the baz key set to quux Finished in 0.00189 seconds (files took 0.10379 seconds to load) 4 examples, 0 failures
You can declare a
context
outside of adescribe
block using theshared_context
method. A shared context is a context that can be shared across many example groups. It’s a way to describe common behavior without having to resort to duplicating code. We can break out the common behavior in our example above into a shared context:shared_context 'foo should always be set to bar' do it 'should have the foo key set to bar' do expect(subject[:foo]).to eq 'bar' end end describe Hash do let(:baz_val) { nil } let(:subject) do { :foo => 'bar', :baz => baz_val } end include_context 'foo should always be set to bar' it 'should have the baz key set to nil' do expect(subject[:baz]).to be nil end context 'with baz_val set to qux' do let(:baz_val) { 'qux' } include_context 'foo should always be set to bar' it 'should have the baz key set to qux' do expect(subject[:baz]).to eq 'qux' end end context 'with baz_val set to quux' do let(:baz_val) { 'quux' } include_context 'foo should always be set to bar' it 'should have the baz key set to quux' do expect(subject[:baz]).to eq 'quux' end end end
Since the
foo
key should always be set tobar
, we can include this shared context to ensure that ourHash
meets our expectations in all of our contexts. Here’s the output of that test:Hash should have the foo key set to bar should have the baz key set to nil with baz_val set to qux should have the foo key set to bar should have the baz key set to qux with baz_val set to quux should have the foo key set to bar should have the baz key set to quux Finished in 0.00221 seconds (files took 0.08721 seconds to load) 6 examples, 0 failures
Shared contexts are a powerful way to describe common behavior without resorting to boilerplate. Shared contexts can be stored in separate files and loaded using the
require
method at the beginning of yourspec
file.Making the connection
This post focused mostly on RSpec itself. In a future post I’ll bring these elements together with some examples using
rspec-puppet
andserverspec
.