avatarScott Matthewman

Free AI web copilot to create summaries, insights and extended knowledge, download it at here

8267

Abstract

re no pets, and thus no fights, and the test fails. (The test above it would fail for the same reason.)</p><figure id="d6ed"><img src="https://cdn-images-1.readmedium.com/v2/resize:fit:800/1*oIE_OIxU6-Fx-U7JufFFSg.png"><figcaption></figcaption></figure><p id="6c55">If we rewrite those tests out <i>without</i> leaning on <code>let</code> and <code>before</code> blocks, we’d get something like:</p><div id="1309"><pre>RSpec.describe ‘Arbuckle household’ <span class="hljs-keyword">do</span> it ‘has <span class="hljs-keyword">no</span> daily fights <span class="hljs-keyword">when</span> Garfield <span class="hljs-keyword">and</span> Odie are friends’ <span class="hljs-keyword">do</span> jon = Person.<span class="hljs-built_in">new</span> garfield = Cat.<span class="hljs-built_in">new</span>(<span class="hljs-keyword">owner</span>: jon) odie = Dog.<span class="hljs-built_in">new</span>(<span class="hljs-keyword">owner</span>: jon) household = Household.<span class="hljs-built_in">new</span>(jon)

garfield.enemies = []

expect(household.daily_fights).<span class="hljs-keyword">to</span> be_empty

<span class="hljs-keyword">end</span>

it ‘has at least one daily fight <span class="hljs-keyword">when</span> Garfield <span class="hljs-keyword">and</span> Odie are enemies’ <span class="hljs-keyword">do</span> jon = Person.<span class="hljs-built_in">new</span> garfield = Cat.<span class="hljs-built_in">new</span>(<span class="hljs-keyword">owner</span>: jon) odie = Dog.<span class="hljs-built_in">new</span>(<span class="hljs-keyword">owner</span>: jon) household = Household.<span class="hljs-built_in">new</span>(jon)

garfield.enemies = [odie]

expect(household.daily_fights.size).<span class="hljs-keyword">to</span> be_positive

<span class="hljs-keyword">end</span> <span class="hljs-keyword">end</span></pre></div><p id="c560">We have repetition here, but we also have a linear narrative. The order in which we define objects doesn’t actually change, but we now have a clear arrange-act-assert structure which makes it easy to see what we’re doing, what we’re checking, and why. This makes it easier to see that the tests should pass — but if they don’t, it’ll also be easier to see where and why the failure happens.</p><h2 id="aadb">Refactor with purpose</h2><p id="d8a9">RSpec gives us lots of ways to tidy up our code, including:</p><ul><li><code>let</code> and its partner, <code>let!</code></li><li>Implicit and explicit <code>subject</code></li><li><code>before</code>, <code>around</code> and <code>after</code> blocks</li><li><code>describe</code> and <code>context</code> blocks</li><li>Conditional setup tied to metadata tags</li><li>Shared examples</li></ul><p id="e801">We’ll often have test suites that also use FactoryBot or similar to create example objects, or seed data with which to prime the test database before every run.</p><p id="d76c">All of these are useful, and have their place. But they are <b>refactoring tools</b>. And just as you wouldn’t refactor executable code as part of your first writing step, you shouldn’t lean on some of these tools while writing your tests.</p><p id="5652"><b>Every time you refactor a test, you risk making it harder to read.</b> And a test that can’t be easily read and digested by a human has limited worth as a test. Yes, it’s important that computers can execute your test specs, but computers aren’t the people who will have to look at tests when something has gone wrong, when a test that has been happily passing for some time — years, even — suddenly turns red.</p><p id="2d25">When it’s your job to find out why something’s gone wrong, you need your tests to speed you up, not slow you down.</p><p id="c045">When I proposed the talk at LRUG, I gave it the subtitle <b>Ways to up your game with RSpec</b>. But I didn’t really need the plural; there is one way to immediately make your RSpec life, and that of your work colleagues, much better.</p><blockquote id="14c0"><p>Don’t DRY your specs</p></blockquote><p id="ea6b">Now I don’t mean you can’t refactor. Refactoring is an essential part of the development process, for tests as much as for executable code. But you should always be clear about the purpose of such refactoring.</p><p id="0d87">The goal should be <b>clarity</b> and <b>readability</b>. Refactoring for the sake of refactoring, or to explicitly make your tests DRY, should be avoided.</p><p id="787e">Indeed, if you’re working on an existing codebase, a bigger and better refactoring step might be to “un-DRY” an existing test. If you’re working with a codebase that you don’t really know, and the tests aren’t helping, this can be a useful exercise not only to make the tests more readable and understandable, but to help you learn about the codebase also.</p><h2 id="f9f7">A real world example</h2><p id="5620">The example below is taken from a genuine production codebase I have encountered in my years of Rails development; I won’t say from where. Method and class names have been changed to spare the blushes of the previous developer(s).</p><p id="76f5">The following spec was part of a system where users had been reporting failures:</p><div id="ee1e"><pre>context <span class="hljs-string">'when psid is not found'</span> <span class="hljs-keyword">do</span> let(<span class="hljs-symbol">:phone_number</span>) { <span class="hljs-string">'+12223334444'</span> }

it <span class="hljs-string">'raises an error'</span> <span class="hljs-keyword">do</span> expect { subject }.to raise_error(<span class="hljs-title class_">VoIP</span>::<span class="hljs-title class_">Error</span>) <span class="hljs-keyword">end</span> <span class="hljs-keyword">end</span></pre></div><p id="308b">This instantly throws up some questions. What is a <code>psid</code>? What relation to a phone number does it have? What are we doing with either (or both) that would cause an error to be raised?</p><p id="ded5">Right at the top of the file, there are some clues:</p><div id="5ce4"><pre>RSpec<span class="hljs-selector-class">.describe</span> VoIP::Router do <span class="hljs-built_in">let</span>(:psid_session) { <span class="hljs-built_in">create</span>(:voip_psid_session) } <span class="hljs-built_in">let</span>(:phone_number) { psid_session<span class="hljs-selector-class">.psid</span><span class="hljs-selector-class">.phone_number</span> } <span class="hljs-built_in">let</span>(:sid) { <span class="hljs-string">'ca0e2f3e...'</span> }

subject { described_class<span class="hljs-selector-class">.new</span>(phone_number: phone_number, sid: sid)<span class="hljs-selector-class">.call</span> }</pre></div><p id="ef4c">So our <code>subject</code> is an instance of <code>VoIP::Router</code> that is initialised with a phone number and a <code>sid</code> (session ID, perhaps?), and then has its <code>call</code> method executed. So in our arrange-act-assert story, <code>subject</code> is both arrangement and action.</p><p id="09f1">By default, <code>phone_number</code> is calculated; it comes from the creation of a <code>psid_session</code> object, with <code>create</code> calling a FactoryBot factory. That seems to create a child <code>psid</code> object that has a phone number property.</p><p id="e397">While that still poses some questions, ultimately it explains the context block we started with. We are overriding the default <code>let(:phone_number)</code> declaration, replacing it with one that contains a text string that, one presumes, is not related to a <code>psid</code> or <code>psid_session</code> in any way.</p><p id="985a">Knowing that, our original spec can be written to add that clarity:</p><div id="b04a"><pre><span class="hljs-literal">it</span> <span class="hljs-string">'raises an error when the call comes from an unrecognised number'</span> <span class="hljs-keyword">do</span> unrecognised_number = <span class="hljs-string">'+1222333444'</span> valid_session_id = <span class="hljs-string">'ca0e2f3e...'</span> router = described_class.<span class="hljs-keyword">new</span>(phone_number: wrong_number, sid: valid_session_id)

expect { router.run }.<span class="hljs-keyword">to</span> raise

Options

_error(VoIP::<span class="hljs-built_in">Error</span>) end</pre></div><p id="23b1">And now we have a test that tells a linear story, and is much more useful. We’re no longer overriding any previously declared variables, so we can rename <code>phone_number</code> to give further context.</p><p id="898e">And as I apply the same principles to other specs in the same file, I may find that <code>valid_session_id</code> is repeated throughout. That’s not necessarily part of these specs’ story, though, so refactoring it out could be of benefit:</p><div id="1375"><pre>let(:valid_session_id) { <span class="hljs-string">'ca0e2f3e...'</span> }

it <span class="hljs-string">'raises an error when the call comes from an unrecognised number'</span> <span class="hljs-keyword">do</span> unrecognised_number = <span class="hljs-string">'+1222333444'</span> router = described_class.<span class="hljs-built_in">new</span>(phone_number: wrong_number, sid: valid_session_id) <span class="hljs-keyword">end</span>

it <span class="hljs-string">'increases the session log count when an existing session exists'</span> <span class="hljs-keyword">do</span> phone_number = <span class="hljs-string">'+44700123456'</span> psid = <span class="hljs-keyword">create</span>(:psid) <span class="hljs-keyword">session</span> = <span class="hljs-keyword">create</span>(:voip_psid_session, psid: psid) router = described_class.<span class="hljs-built_in">new</span>(phone_number: phone_number, sid: valid_session_id)

etc

<span class="hljs-keyword">end</span></pre></div><p id="83a7">You can see that by moving the <code>valid_session_id</code> out, our tests retain their linear structure, while concentrating on the elements of our system knowledge that are actually being tested.</p><p id="eb68">You can also see that, rather than relying on FactoryBot to create a nested graph of objects and then use a phone number from there, I’ve inverted the principle — start with a phone number, and create the objects that should be associated with it.</p><p id="71c2">This is where the repetition of non-DRY specs can start to impact readability. And if you choose to refactor some of that repetition away, it <i>could</i> make the tests more readable.</p><p id="dc9f">The exact method you choose will depend on more than a single spec, but I would suggest that the closer any refactored methods are to the specs, the easier they are going to be to understand. For example, you could decide to include a helper method within your spec:</p><div id="0e18"><pre>let(<span class="hljs-symbol">:valid_session_id</span>) { <span class="hljs-string">'ca0e2f3e...'</span> }

<span class="hljs-keyword">def</span> <span class="hljs-title function_">build_session</span>(<span class="hljs-params"><span class="hljs-symbol">phone_number:</span></span>) psid = create(<span class="hljs-symbol">:psid</span>) create(<span class="hljs-symbol">:voip_psid_session</span>, <span class="hljs-symbol">psid:</span> psid) <span class="hljs-keyword">end</span>

it <span class="hljs-string">'increases the session log count when an existing session exists'</span> <span class="hljs-keyword">do</span> phone_number = <span class="hljs-string">'+44700123456'</span> session = build_session(<span class="hljs-symbol">phone_number:</span> phone_number) router = described_class.new(<span class="hljs-symbol">phone_number:</span> phone_number, <span class="hljs-symbol">sid:</span> valid_session_id) <span class="hljs-comment"># etc</span> <span class="hljs-keyword">end</span></pre></div><p id="f049">You can still use FactoryBot to create persisted versions of your objects, but keeping their creation inside your spec, rather than relying on its automatically built associations, can keep a nice balance between readability and convenience.</p><p id="ffc2">The key, I believe, is that <b>extracting code that gets in the way of your arrange-act-assert story</b> is OK. But even then, <b>be careful</b>, <b>be cautious</b> and <b>always prioritise readability</b>.</p><h1 id="3477">If not DRY, then what?</h1><p id="e24c">In Working with Unit Tests, Jay Fields came up with his own idea for a replacement methodology that is not DRY.</p><blockquote id="e012"><p><b>DAMP</b></p></blockquote><blockquote id="4077"><p>Descriptive And Maintainable Procedures</p></blockquote><p id="f334">That’s clearly a backronym — the words have been chosen to fit the amusing word choice. I personally find that phrasing a bit dry in its other sense.</p><p id="65f8">Instead, I think about <b>DRY</b> as a directive: Don’t Repeat Yourself. It is an instruction to be carried out.</p><p id="e25e">And so its alternative should be the same, too. Give yourself permission to be clear in your test code. Be ready and willing to accept that such clarity includes, even requires, repetition.</p><p id="ff0e">So repeat after me: <b>Go Ahead, Repeat Yourself.</b></p><p id="6c79"><b>GARY.</b></p><h2 id="0152">Get some help</h2><p id="c867">Now, if your codebase is making as good a use of style checking tools as I hope you are, you may find that, for example, <code>rubocop</code> complains when your new, longer style of spec writing breaks its metrics rules.</p><p id="0e88">But remember: your codebase contains executable code, and test code, and we have <b>different writing styles for each</b>. If your rubocop config doesn’t accept that, then it becomes of less use.</p><p id="0f39">How you choose to configure these tools is up to you — but you will probably find that creating a custom config for your <code>spec/</code> folder helps greatly.</p><p id="7429">For example, the following config file, <code>spec/.rubocop.yml</code> applies custom rules just to the spec folder, while using the app-wide config as a base:</p><div id="c0b0"><pre><span class="hljs-comment"># spec/.rubocop.yml</span>

<span class="hljs-attribute">inherit_from</span><span class="hljs-punctuation">:</span> <span class="hljs-string">../.rubocop.yml</span>

<span class="hljs-attribute">Metrics/BlockLength</span><span class="hljs-punctuation">:</span> <span class="hljs-attribute">AllowedMethods</span><span class="hljs-punctuation">:</span> <span class="hljs-bullet">-</span> <span class="hljs-string">it</span> <span class="hljs-bullet">-</span> <span class="hljs-string">specify</span> <span class="hljs-bullet">-</span> <span class="hljs-string">context</span> <span class="hljs-bullet">-</span> <span class="hljs-string">describe</span> <span class="hljs-comment"># etc.</span></pre></div><p id="ff4f">The above config doesn’t disable <code>Metrics/BlockLength</code> in your specs completely, but it does allow RSpec’s blocks, <code>it</code> and <code>describe</code> (and their synonyms, <code>specify</code> and <code>context</code>) to opt out of metric checks, so that your specs can be as long as they need to be.</p><p id="a5a2">Find out what works for you, and allows rubocop to keep your test writing style neat without compromising readability. These tools exist to assist you, not control you.</p><h2 id="1f92">Further reading</h2><p id="1000">Jay Fields’s <i>Working Effectively with Unit Tests</i> is great on writing readable, maintainable unit tests. Its worked examples use Java and JUnit, but I found that working through the book and thinking how to apply the principles to Ruby and RSpec enabled me to get a huge amount out of it.</p><p id="d482"><a href="https://leanpub.com/wewut">https://leanpub.com/wewut</a></p><p id="79aa">For going in depth into RSpec, I’d recommend Aaron Sumner’s <i>Everyday Rails Testing with RSpec</i>. The current edition is some years old now, but a new edition covering Rails 7.1 and advances in testing, front end development and more is in the works. Buying the current edition entitles you to a free upgrade to the new book when it’s released.</p><p id="a958"><a href="https://leanpub.com/everydayrailsrspec">https://leanpub.com/everydayrailsrspec</a></p><h1 id="a100">To repeat…</h1><p id="4b98"><b>Executable code should be DRY.</b></p><p id="7d22"><b>Test code should be GARY.</b></p><p id="1446">Go Ahead. Repeat Yourself. Be more GARY.</p></article></body>

Be More GARY: Upping your RSpec game

Improve your test code by moving away from the DRY principle

This article is based on a lightning talk given at LRUG’s February 2024 meetup.

In the Ruby community, the same acronyms have a tendency to pop up frequently. There’s MINASWAN (Matz Is Nice And So We Are Nice) and YAGNI (You Ain’t Gonna Need It), of course. But perhaps the one we encounter most is DRY, or Don’t Repeat Yourself.

“Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.”

http://wiki.c2.com/?DontRepeatYourself

And so we often find ourself talking about “DRYing” our code, avoiding repetitive code blocks and using that to drive our refactoring processes.

And that’s a good thing. Our executable code is where our system knowledge resides, so unnecessary duplication risks violating that “single representation” principle. We often talk about a “single source of truth” in terms of data, and that’s true for executable code too.

But what about tests?

It’s easy to fall into the same practice when writing tests, especially with RSpec. If the same lines appear in multiple tests, there’s a tendency to extract the repetition. RSpec gives us plenty of tools to do so, from let statements to before blocks, shared examples, conditional meta tagging and more.

But DRYing up your tests can be counter-productive. Indeed, I’d go further and say it usually is counter-productive.

While executable code contains our system knowledge, our test code does not. Instead, it describes our system knowledge, verifies it by exercising it, and documents it by showing its application in action.

So executable code and test code are two different collections of code, with two different purposes. And that means that two different writing styles.

In Working Effectively with Unit Tests, Jay Fields writes:

“Tests do not, or at least should not collaborate; it’s universally accepted that inter-test dependency is an anti-pattern… code that appears in one test should not necessarily be considered inadvisable duplication if it appears in another test as well”

While Aaron Sumner puts it a little more succinctly in Everyday Rails Testing With RSpec:

“In the case of tests, readability is more important than DRY”

And that’s because the most important part of tests is that they are storytellers.

Tests tell a story

Stories traditionally have a beginning, a middle, and an end. In tests, that translates to Arrange, Act, Assert. We begin by arranging our system data into a known state; we perform an action; and we end by asserting that our expected outcome has actually occurred.

# Arrange
garfield = Cat.new

# Act
garfield.feed(:lasagne)

# Assert
expect(garfield.mood).to eq(:happy)

And then we repeat for each scenario that our system knowledge encompasses.

# Arrange
garfield = Cat.new(mood: :happy)
odie = Dog.new
garfield.enemies << odie

# Act
odie.speak('Arf!')

# Assert
expect(garfield.mood).to eq(:annoyed)

We typically arrange multiple specs for the same object in a single file, which allows us to extract common setup, group specs together, and so on. For example, take this spec which looks at a household, and seeks to establish that when its pets are enemies, there is at least one daily fight:

This has been arranged using RSpec’s most common utilities, let and before, to DRY up the spec in ways that are prevalent in our community. Chances are that if you haven’t written specs like this yourself, you work on a project which has a spec like this.

And because each spec is a single line, it looks clean, doesn’t it?

But let’s walk through the bottom test:

context 'When Odie and Garfield are friends' do
  # ...etc...
  it 'has no daily fights' do
    expect(household.daily_fights).to be_empty
  end
end

While that looks like a single line spec, in reality it’s far from it.

The spec actually starts in the before block above it ❶ where we reference garfield. That takes us up to the let(:garfield) declaration ❷ where, as part of the Cat initialiser, we call jon, meaning we take a detour to declare him ❸, and odie ❹. The let(:odie) call also references jon, but let statements are only executed once per spec, so that’s fine. We have now correctly instantiated garfield, so can return to the before block ❺.

Here, the enemies array declared in the let(:garfield) block is discarded. Only now are we in a position to hit the “one line” spec ❻. But wait! Here we’re referencing household, which we’ve not seen before in this test, so we should head back to the list of let statements ❼. The household definition also references jon, but that’s okay, he’s still instantiated, so we can return to the spec ❽, where we can attest that, as the two pets are no longer enemies, there are no daily fights.

That’s a lot to unpick for the sake of having one line of code in an it block.

And this sort of structure can hide mistakes, too. Not only do we have a let statement that isn’t ever referenced (lasagne), but let’s follow the path for another test:

it 'has at least one daily fight' do
  expect(household.daily_fights.size).to be_positive
end

There’s no before block this time, so we start within the spec itself ❶. This references household, so up we go to its let(:household) declaration ❷. As before, this references jon – but for this spec, this is the first reference, so we must go to let(:jon) ❸. Once that’s declared we can return to the spec ❹.

But wait! This time round, we’ve not referenced garfield or odie at all. As far as the test is concerned, the household only comprises jon – there are no pets, and thus no fights, and the test fails. (The test above it would fail for the same reason.)

If we rewrite those tests out without leaning on let and before blocks, we’d get something like:

RSpec.describe ‘Arbuckle household’ do
  it ‘has no daily fights when Garfield and Odie are friends’ do
    jon = Person.new
    garfield = Cat.new(owner: jon)
    odie = Dog.new(owner: jon)
    household = Household.new(jon) 
    
    garfield.enemies = []

    expect(household.daily_fights).to be_empty
  end

  it ‘has at least one daily fight when Garfield and Odie are enemies’ do
    jon = Person.new
    garfield = Cat.new(owner: jon)
    odie = Dog.new(owner: jon)
    household = Household.new(jon) 
    
    garfield.enemies = [odie]

    expect(household.daily_fights.size).to be_positive
  end
end

We have repetition here, but we also have a linear narrative. The order in which we define objects doesn’t actually change, but we now have a clear arrange-act-assert structure which makes it easy to see what we’re doing, what we’re checking, and why. This makes it easier to see that the tests should pass — but if they don’t, it’ll also be easier to see where and why the failure happens.

Refactor with purpose

RSpec gives us lots of ways to tidy up our code, including:

  • let and its partner, let!
  • Implicit and explicit subject
  • before, around and after blocks
  • describe and context blocks
  • Conditional setup tied to metadata tags
  • Shared examples

We’ll often have test suites that also use FactoryBot or similar to create example objects, or seed data with which to prime the test database before every run.

All of these are useful, and have their place. But they are refactoring tools. And just as you wouldn’t refactor executable code as part of your first writing step, you shouldn’t lean on some of these tools while writing your tests.

Every time you refactor a test, you risk making it harder to read. And a test that can’t be easily read and digested by a human has limited worth as a test. Yes, it’s important that computers can execute your test specs, but computers aren’t the people who will have to look at tests when something has gone wrong, when a test that has been happily passing for some time — years, even — suddenly turns red.

When it’s your job to find out why something’s gone wrong, you need your tests to speed you up, not slow you down.

When I proposed the talk at LRUG, I gave it the subtitle Ways to up your game with RSpec. But I didn’t really need the plural; there is one way to immediately make your RSpec life, and that of your work colleagues, much better.

Don’t DRY your specs

Now I don’t mean you can’t refactor. Refactoring is an essential part of the development process, for tests as much as for executable code. But you should always be clear about the purpose of such refactoring.

The goal should be clarity and readability. Refactoring for the sake of refactoring, or to explicitly make your tests DRY, should be avoided.

Indeed, if you’re working on an existing codebase, a bigger and better refactoring step might be to “un-DRY” an existing test. If you’re working with a codebase that you don’t really know, and the tests aren’t helping, this can be a useful exercise not only to make the tests more readable and understandable, but to help you learn about the codebase also.

A real world example

The example below is taken from a genuine production codebase I have encountered in my years of Rails development; I won’t say from where. Method and class names have been changed to spare the blushes of the previous developer(s).

The following spec was part of a system where users had been reporting failures:

context 'when psid is not found' do
  let(:phone_number) { '+12223334444' }

  it 'raises an error' do
    expect { subject }.to raise_error(VoIP::Error)
  end
end

This instantly throws up some questions. What is a psid? What relation to a phone number does it have? What are we doing with either (or both) that would cause an error to be raised?

Right at the top of the file, there are some clues:

RSpec.describe VoIP::Router do 
  let(:psid_session)  { create(:voip_psid_session) }
  let(:phone_number)  { psid_session.psid.phone_number }
  let(:sid)           { 'ca0e2f3e...' }

  subject { described_class.new(phone_number: phone_number, sid: sid).call }

So our subject is an instance of VoIP::Router that is initialised with a phone number and a sid (session ID, perhaps?), and then has its call method executed. So in our arrange-act-assert story, subject is both arrangement and action.

By default, phone_number is calculated; it comes from the creation of a psid_session object, with create calling a FactoryBot factory. That seems to create a child psid object that has a phone number property.

While that still poses some questions, ultimately it explains the context block we started with. We are overriding the default let(:phone_number) declaration, replacing it with one that contains a text string that, one presumes, is not related to a psid or psid_session in any way.

Knowing that, our original spec can be written to add that clarity:

it 'raises an error when the call comes from an unrecognised number' do
  unrecognised_number = '+1222333444'
  valid_session_id = 'ca0e2f3e...'
  router = described_class.new(phone_number: wrong_number, 
                               sid: valid_session_id)

  expect { router.run }.to raise_error(VoIP::Error)
end

And now we have a test that tells a linear story, and is much more useful. We’re no longer overriding any previously declared variables, so we can rename phone_number to give further context.

And as I apply the same principles to other specs in the same file, I may find that valid_session_id is repeated throughout. That’s not necessarily part of these specs’ story, though, so refactoring it out could be of benefit:

let(:valid_session_id) { 'ca0e2f3e...' }

it 'raises an error when the call comes from an unrecognised number' do
  unrecognised_number = '+1222333444'
  router = described_class.new(phone_number: wrong_number, 
                               sid: valid_session_id)
end

it 'increases the session log count when an existing session exists' do
  phone_number = '+44700123456'
  psid = create(:psid)
  session = create(:voip_psid_session, psid: psid)
  router = described_class.new(phone_number: phone_number,
                               sid: valid_session_id)
  # etc
end

You can see that by moving the valid_session_id out, our tests retain their linear structure, while concentrating on the elements of our system knowledge that are actually being tested.

You can also see that, rather than relying on FactoryBot to create a nested graph of objects and then use a phone number from there, I’ve inverted the principle — start with a phone number, and create the objects that should be associated with it.

This is where the repetition of non-DRY specs can start to impact readability. And if you choose to refactor some of that repetition away, it could make the tests more readable.

The exact method you choose will depend on more than a single spec, but I would suggest that the closer any refactored methods are to the specs, the easier they are going to be to understand. For example, you could decide to include a helper method within your spec:

let(:valid_session_id) { 'ca0e2f3e...' }

def build_session(phone_number:)
  psid = create(:psid)
  create(:voip_psid_session, psid: psid)
end

it 'increases the session log count when an existing session exists' do
  phone_number = '+44700123456'
  session = build_session(phone_number: phone_number)
  router = described_class.new(phone_number: phone_number,
                               sid: valid_session_id)
  # etc
end

You can still use FactoryBot to create persisted versions of your objects, but keeping their creation inside your spec, rather than relying on its automatically built associations, can keep a nice balance between readability and convenience.

The key, I believe, is that extracting code that gets in the way of your arrange-act-assert story is OK. But even then, be careful, be cautious and always prioritise readability.

If not DRY, then what?

In Working with Unit Tests, Jay Fields came up with his own idea for a replacement methodology that is not DRY.

DAMP

Descriptive And Maintainable Procedures

That’s clearly a backronym — the words have been chosen to fit the amusing word choice. I personally find that phrasing a bit dry in its other sense.

Instead, I think about DRY as a directive: Don’t Repeat Yourself. It is an instruction to be carried out.

And so its alternative should be the same, too. Give yourself permission to be clear in your test code. Be ready and willing to accept that such clarity includes, even requires, repetition.

So repeat after me: Go Ahead, Repeat Yourself.

GARY.

Get some help

Now, if your codebase is making as good a use of style checking tools as I hope you are, you may find that, for example, rubocop complains when your new, longer style of spec writing breaks its metrics rules.

But remember: your codebase contains executable code, and test code, and we have different writing styles for each. If your rubocop config doesn’t accept that, then it becomes of less use.

How you choose to configure these tools is up to you — but you will probably find that creating a custom config for your spec/ folder helps greatly.

For example, the following config file, spec/.rubocop.yml applies custom rules just to the spec folder, while using the app-wide config as a base:

# spec/.rubocop.yml

inherit_from: ../.rubocop.yml

Metrics/BlockLength:
  AllowedMethods:
    - it
    - specify
    - context
    - describe
# etc.

The above config doesn’t disable Metrics/BlockLength in your specs completely, but it does allow RSpec’s blocks, it and describe (and their synonyms, specify and context) to opt out of metric checks, so that your specs can be as long as they need to be.

Find out what works for you, and allows rubocop to keep your test writing style neat without compromising readability. These tools exist to assist you, not control you.

Further reading

Jay Fields’s Working Effectively with Unit Tests is great on writing readable, maintainable unit tests. Its worked examples use Java and JUnit, but I found that working through the book and thinking how to apply the principles to Ruby and RSpec enabled me to get a huge amount out of it.

https://leanpub.com/wewut

For going in depth into RSpec, I’d recommend Aaron Sumner’s Everyday Rails Testing with RSpec. The current edition is some years old now, but a new edition covering Rails 7.1 and advances in testing, front end development and more is in the works. Buying the current edition entitles you to a free upgrade to the new book when it’s released.

https://leanpub.com/everydayrailsrspec

To repeat…

Executable code should be DRY.

Test code should be GARY.

Go Ahead. Repeat Yourself. Be more GARY.

Ruby
Development
Rspec
Unit Testing
Dry
Recommended from ReadMedium