Testing Ledger Behavior

We’ve seen how to run Sequel migrations manually from the command line. You’ll need to configure RSpec to run them automatically, so that the database structure is in place before your first integration spec runs.

The following code will make sure the database structure is set up and empty, ready for your specs to add data to it:

 Sequel.extension ​:migration
 Sequel::Migrator.run(DB, ​'db/migrations'​)
 DB[​:expenses​].truncate

First, we run all the migration files in to make sure that all the database tables exist with their current schema. Then, we remove any leftover test data from the table using the truncate method. That way, each run of the spec suite starts with a clean database—no matter what happened before.

The only problem is that it’s not obvious where to put these lines. Up to this point, you’ve tended to keep setup routines like this in one of two places:

  • The top of a single spec file
  • The global spec_helper.rb file

Database migrations sit somewhere in between these two extremes. We want to load them for any spec that touches the database, but not for unit specs—those need to stay snappy, even as our database migrations grow over the lifetime of the application.

The RSpec convention for this kind of “partially shared” code is to put it in a folder called spec/support; then we can load it from whatever spec files need it. Create a new file called spec/support/db.rb with the following content:

 RSpec.configure ​do​ |c|
  c.before(​:suite​) ​do
  Sequel.extension ​:migration
  Sequel::Migrator.run(DB, ​'db/migrations'​)
  DB[​:expenses​].truncate
 end
 end

This snippet defines a suite-level hook. We first encountered before hooks in Hooks. A typical hook will run before each example. This one will run just once: after all the specs have been loaded, but before the first one actually runs. That’s what before(:suite) hooks are for.

Bootstrap Your Environment for Easy Testing

images/aside-icons/info.png

Your spec suite should set up the test database for you, rather than requiring you to run a separate setup task. People testing your code (including you!) can easily forget to run the extra step, or might not even know they need to.

Now that we have defined our hook, we are ready to define our spec and load the support file. Create a file called spec/integration/app/ledger_spec.rb with the following code:

 require_relative ​'../../../app/ledger'
 require_relative ​'../../../config/sequel'
 require_relative ​'../../support/db'
 
 module​ ExpenseTracker
  RSpec.describe Ledger ​do
 let​(​:ledger​) { Ledger.new }
 let​(​:expense​) ​do
  {
 'payee'​ => ​'Starbucks'​,
 'amount'​ => 5.75,
 'date'​ => ​'2017-06-10'
  }
 end
 
 describe​ ​'#record'​ ​do
 # ... contexts go here ...
 end
 end
 end

The :ledger and :expense setup will be the same for each example, so we’ve used let to initialize this data.

Now, it’s time to spell out the behavior we want in the first example. We want to tell the ledger to save the expense, and then actually read the database from disk and make sure the expense really got saved:

 context​ ​'with a valid expense'​ ​do
 it​ ​'successfully saves the expense in the DB'​ ​do
  result = ledger.record(expense)
 
 expect​(result).to be_success
 expect​(DB[​:expenses​].all).to match [a_hash_including(
 id: ​result.expense_id,
 payee: ​​'Starbucks'​,
 amount: ​5.75,
 date: ​Date.iso8601(​'2017-06-10'​)
  )]
 end
 end

We’re using a couple of new matchers here. The first, be_success, simply checks that result.success? is true. This matcher is built into RSpec; you’ll learn more about it in Dynamic Predicates.

The second matcher, match [a_hash_including(...)], expects our app to return data matching a certain structure; in this case, a one-element array of hashes with certain keys and values. This expression is another use of RSpec’s composable matchers; here, you’re passing the a_hash_including matcher into the match one.

We’re deviating a bit from general TDD practice in this snippet. Normally, each example would only have one expectation in it—otherwise, one failure can mask another. Here, we’ve got two expectations in the same example.

There’s a bit of a trade-off to consider here. Any spec that touches the database is going to be slower, particularly in its setup and teardown steps. If we follow “one expectation per example” too rigorously, we’re going to be repeating that setup and teardown many times. By judiciously combining a couple of assertions, we’re keeping our suite speedy.

Let’s see what we’re giving up to get this performance boost. Go ahead and run your specs:

 $ ​​bundle exec rspec spec/integration/app/ledger_spec.rb
  truncated
 
 Failures:
 
  1) ExpenseTracker::Ledger#record with a valid expense successfully saves
  the expense in the DB
  Failure/Error: expect(result).to be_success
  expected nil to respond to ‘success?‘
  # ./spec/integration/app/ledger_spec.rb:23:in ‘block (4 levels) in
  <module:ExpenseTracker>’
 
 Finished in 0.02211 seconds (files took 0.15418 seconds to load)
 1 example, 1 failure
 
 Failed examples:
 
 rspec ./spec/integration/app/ledger_spec.rb:20 #
 ExpenseTracker::Ledger#record with a valid expense successfully saves the
 expense in the DB
 
 Randomized with seed 27984

As you’d expect, the spec failed on the first assertion. We never even see the second assertion, because by default, RSpec aborts the test on the first failure.

It would be nice to record the first failure, but continue to try the second expectation. Good news! The :aggregate_failures tag does just that. Change your example declaration to the following:

 it​ ​'successfully saves the expense in the DB'​, ​:aggregate_failures​ ​do

Now, RSpec’s output shows both failures underneath a description of the example:

 $ ​​bundle exec rspec spec/integration/app/ledger_spec.rb
  truncated
 
 Failures:
 
  1) ExpenseTracker::Ledger#record with a valid expense successfully saves
  the expense in the DB
  Got 1 failure and 1 other error:
 
  1.1) Failure/Error: expect(result).to be_success
  expected nil to respond to ‘success?‘
  # ./spec/integration/app/ledger_spec.rb:23:in ‘block (4 levels)
  in <module:ExpenseTracker>’
 
  1.2) Failure/Error: id: result.expense_id,
 
  NoMethodError:
  undefined method ‘expense_id’ for nil:NilClass
  # ./spec/integration/app/ledger_spec.rb:25:in ‘block (4 levels)
  in <module:ExpenseTracker>’
 
 Finished in 0.02142 seconds (files took 0.15952 seconds to load)
 1 example, 1 failure
 
 Failed examples:
 
 rspec ./spec/integration/app/ledger_spec.rb:20 #
 ExpenseTracker::Ledger#record with a valid expense successfully saves the
 expense in the DB
 
 Randomized with seed 41929

We’re likely to want our other integration tests to get this benefit. Move the :aggregate_failures property up one level from the example to the group:

 RSpec.describe Ledger, ​:aggregate_failures​ ​do

RSpec refers to these properties of specs as metadata. When you define metadata—arbitrary symbols or hashes—on an example group, all the examples in that group inherit it, as do any nested groups. We previously saw an example of metadata being specified as a hash in Tag Filtering. Here we are defining our metadata using just a symbol as a shortcut. Internally, RSpec expands this into a hash like { aggregate_failures: true }.

Now, it’s time to fill in the behavior. The Ledger class’s record method needs to store the expense in the database, then return a RecordResult indicating what happened:

 def​ record(expense)
  DB[​:expenses​].insert(expense)
  id = DB[​:expenses​].max(​:id​)
  RecordResult.new(​true​, id, ​nil​)
 end

When you rerun RSpec, your integration spec should pass now.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset