Testing with RSpec
What are you going to learn?
- Understand the basic implementation of a test
- Why it is important to test our code
- User RSpec to create automated tests
Automated test
To talk about test automation, we necessarily need to understand Test Driven Development as a way to create tests. First we need to define what test automation is:
the use of software separate from the software being tested to control the execution of tests and the comparison of actual outcomes with predicted outcomes
Having automated tests on our code, we will:
- Be able to add or remove code with confidence
- Document our executing code through code.
- Keep happiness levels up for the developer
- Implement CI/CD strategies
- Integrate other team members with ease
- Increase the code catch up curve
TDD
Is an evolutionary approach to development which combines test-first development where you write a test before you write just enough production code to fulfill that test and refactoring
This approach to create tests allow developers to keep things simple by taking small steps which lead to good code design as it enable and motivate us to refactor small portions of our code, while receiving inmediate feedback.
We have to be honest, this is not an easy practice to adopt, it may be frustating and time-consuming at first, but once you get to it, we truly believe the pay-off is worth it. Some of the misconceptions of myths you may hear when using TDD area:
- Test 100% of your code. This isn't realistic for several reasons, for example, the team not having the enough skill, the user interface is not easy to test, maybe you are working with legacy code, that you are unable to add tests to. Is better to define a threshold for this, 70% of coverage for example.
- Unit testing is enough. Some may argue that by having all of our classes tested, it is enough. In reality this is not the case, for example, user interface testing may be a crucial component for your application to work, and it will make sense to add integration tests.
- TDD does not scale. As your code grows, your tests will take longer to execute, and while this is a fact, there are ways to improve the workflow, and almost remove thid concern. The fact that you have slow tests, may be an indication of code that is coupled and may be a good time to decouple.
RSpec
There are a bunch of tools to start testing our code, the two most common ones are mini test and rspec. In our case we are going to be using RSpec.
RSpec is a 'Domain Specific Language' testing tool written in Ruby to test Ruby code. It is a behavior-driven development framework which is extensively used in the production applications
To install rspec, just fire up a terminal and type:
% gem install rspec
To start creating tests, we need to understand first the directory structure we will be creating.
Directory structure
- All of our specs live under a
spec
directory - The implementation code live under a
lib
directory - The spec files should reflect the class being tested with
_spec
appended. Ex -spec/class_to_test_spec.rb
- Each spec file need to also require the
spec_helper.rb
. Ex.require 'spec_helper'
- In your test, you need to require the actual class. Ex.
require ./lib/class_to_test.rb
- You can run your specs by executing -
rspec spec
. You can specify a single file -rspec spec/class_to_test_spec.rb
- You can init your project with the
rspec --init
command
This is how it looks in the wild:
├── lib └── spec └── spec_helper.rb
Writing a test
Assuming you have a directory structure, like the one describe above, this is how you would start creating a test. In this case we will be testing a sum class.
The first step is to create the test file
#spec/sum_spec.rb require 'spec_helper' require 'sum' describe Sum do describe ".run" do it 'returns the 8 by sending 5 and 3' do result = Sum.run(5,3) expect(result).to eql 8 end end end
Here is a description of each part:
Check more on expectations here. For matchers, click here
After writing our first test, we need to see our test fail. So by running rspec spec/sum_spec.rb
you should see a similar output to this:
Failure/Error: require "./lib/sum" LoadError: cannot load such file -- ./lib/sum # ./spec/sum_spec.rb:2:in `<top (required)>' No examples found.
This means that our test is failing, and it is because we don't have a sum file. Let's fix that, by running touch lib/sum.rb
. Remember we are doing the bare minimum to see our test pass.
After creating the file, we run the spec again - rspec spec/sum_spec.rb
An error occurred while loading ./spec/sum_spec.rb. Failure/Error: describe Sum do describe ".run" do it "returns 8, for 3 and 5 as paratemers" do result = Sum.run(3,5) expect(result).to eql 8 end end end NameError: uninitialized constant Sum # ./spec/sum_spec.rb:4:in `<top (required)>' No examples found. Finished in 0.00002 seconds (files took 0.08941 seconds to load) 0 examples, 0 failures, 1 error occurred outside of examples
Now the error has changed, is telling us that the Sum
constant does not exist, in this case, the class, let's fix that:
lib/sum.rb
class Sum end
Now, let's run the specs again - rspec spec/sum_spec.rb
and analyze the output:
F Failures: 1) Sum.run returns 8, for 3 and 5 as paratemers Failure/Error: result = Sum.run(3,5) NoMethodError: undefined method `run' for Sum:Class # ./spec/sum_spec.rb:7:in `block (3 levels) in <top (required)>' Finished in 0.00209 seconds (files took 0.07003 seconds to load) 1 example, 1 failure Failed examples: rspec ./spec/sum_spec.rb:6 # Sum.run returns 8, for 3 and 5 as paratemers
This seems a bit repetitive, but this is actually a real flow for TDD. As long as we receive different error messages, this means we are moving forward. In this case
the problem seems to be, that there is not a run
method for the class.
lib/sum.rb
class Sum def self.run(a, b) end end
Now if we run the specs again - rspec spec/sum_spec.rb
F Failures: 1) Sum.run returns 8, for 3 and 5 as paratemers Failure/Error: expect(result).to eql 8 expected: 8 got: nil (compared using eql?) # ./spec/sum_spec.rb:9:in `block (3 levels) in <top (required)>' Finished in 0.02419 seconds (files took 0.07023 seconds to load) 1 example, 1 failure Failed examples: rspec ./spec/sum_spec.rb:6 # Sum.run returns 8, for 3 and 5 as paratemers
Now we are playing, as you can see, it is actually executing the run
method, but nothing is being performed. Let's fix that.
class Sum def self.run(a, b) a + b end end
Then if we run the tests again - rspec spec/sum_spec.rb
. Finished in 0.00549 seconds (files took 0.18275 seconds to load) 1 example, 0 failures
This is a passing test, the next step would be to refactor, but the code is already simple enough.
Extending our test suite
Let's say there is a new requirement for our Sum
class:
- The
run
method should be able to receive any number of arguments and the sum all of the them.
When receiving a new requirement, our suggestion would be to add one test at a time, and not a bunch of them, cause tackle them would be harder. Let's add our new test:
#spec/sum_spec.rb require 'spec_helper' require 'sum' describe Sum do describe ".run" do it 'returns the 8 by sending 5 and 3' do result = Sum.run(5,3) expect(result).to eql 8 end it 'returns 15 when sending 8, 2 and 5' do result = Sum.run(8, 2, 5) expect(result).to eql 15 end end end
Now, let's see how our test fail:
.F Failures: 1) Sum.run return 15 when sending 8, 2 and 5 Failure/Error: def self.run(a, b) a + b end ArgumentError: wrong number of arguments (given 3, expected 2) # ./lib/sum.rb:2:in `run' # ./spec/sum_spec.rb:13:in `block (3 levels) in <top (required)>' Finished in 0.00323 seconds (files took 0.06782 seconds to load) 2 examples, 1 failure Failed examples: rspec ./spec/sum_spec.rb:12 # Sum.run return 15 when sending 8, 2 and 5
As you can see, the method is expecting 2 arguments, but we are actually sending 3. There are two ways to handle this:
- Add a third argument with a default value of 0, so when calling with 2 arguments, the third one is not necessary. And although this may sound good would you handle it the same with a fourth argument?.
- There is a way for Ruby to call a method with any number of arguments, or even none.
*args
is an array with all the values passed to it.
An implementation for the run
method would be:
class Sum def self.run(*args) args.reduce(:+) end end
Then if we run the tests - rspec spec/sum_spec.rb
.. Finished in 0.00324 seconds (files took 0.06835 seconds to load) 2 examples, 0 failures
We are done, but you may want to add some other tests, for example with negative numbers, or decimals. But we'll leave that to the exercises.
Four-Phase Test
There is a testing pattern called Four-Phase Test, it applies to all programming languages and unit tests.
This is how the four phases look in pseudo-code for each test case:
it do setup exercise verify teardown end
This four steps are executed sequentally.
Setup
On this step, is where you create your instances, or any other task needed for the rest of the spec. For example:
it do pokemon = Pokemon.new("Pikachu") exercise verify teardown end
Exercise
This step is where you execute the method, to get a result and use it later
it do pokemon = Pokemon.new("Pikachu") pokemon.level_up teardown end
Verify
During verification, is where the expectation happen
it do pokemon = Pokemon.new("Pikachu", 1) pokemon.level_up expec(pokemon.level).to eql 2 teardown end
Teardown
This is the step where the cleanup and memory release happens. It is often handle by the same language. In Ruby you don't have to worry abnout this step.
Before we leave
There is a lot of information out there about testing, good practices and much more. We just covered the very basics, and testing along with TDD is mastered through practice, but first, here are some advices:
- You have to see testing as part of your development cycle, not extra steps.
- Testing is an investment on yourself and the team working on the code base. We cannot stress enough how important this is.
- Tests are a way to document your code while providing confidence to the whole team. You included.
- Each test runs independent from others. Avoid tests to run in order on how they were written
- Your tests will (almost)always return
E for Errors
,F for failure
and.
for passing.
Exercises
Remember we have provided a repository with a bunch of exercises for you to complete. You can find it here
You can find them under /ruby-exercises/Module1/testing-with-rspec
.