Interface Driver Testing In Rspec
This article is a continuation from my last article: Interface & Driver Theory and Application in Ruby.
In that article, I laid out the code for a interface-driver pattern.
In this article, I will explore how testing would work and show the natural partition of interface and driver(s) provides easy testing.
Requirements
If you would like to follow along in this article:
- Read my previous article or have a good understanding of interface-driver pattern.
- Have Ruby installed, I used Ruby 2.5.1, but I believe any supported version of Ruby will do.
- Have RSpec installed, I used RSpec 3.7, but I believe any supported version of RSpec will do.
- Code is available in this repository: https://github.com/a-leung/interface-driver-1
There’s no virtual environment at the time.
Biggest Argument Against Testing
When testing objects that have a combined interface-driver functionality, it requires complex setup and take down for every test scenario.
Mocking and stubbing out parts of the driver or driver primitives can
be complex when involving intricate interfaces, even if it is just
standard input and output for a language such as puts
and gets
.
This complexity provides enough frustration that most programmers
(even experienced ones!) to not test, even simple programs. Commands
like puts
just work.
By paying an upfront cost of separating a program into interfaces and drivers, testing becomes easier as we separate testing into two parts: the interface and driver.
Testing Interfaces
Modifying the code from my last article, I set up the interface to accept any driver that supports the functions under test.
I do this so I can create and inject a special TestDriver
that is
only in the spec files (and not in the actual code!)
With this modification, a driver as simple as this is sufficient to
handle testing the write
aspects of the interface:
Yup, that’s it. Nothing that actually writes to any device, but I would like to know the driver just works. I will leave testing a real driver implementation for the driver test.
Sample Interface Tests
Tests done at the interface level:
1
2
3
4
5
6
7
8
9
10
let(:interface) { UserInterface.new(test_driver) }
it 'writes anything passed in' do
written_string = 'write'
expect(interface.write(written_string)).to eq(written_string)
end
it 'formats large numbers nicely' do
expect(interface.write(1000)).to eq("1,000")
end
The driver is an object that works with a lower system. I expect it to just work. I ask the driver to write, it better write. (Otherwise, things are really wrong in this case!) Hence the first test is pretty simple.
The interface I want to focus on more transformative logic. That’s where the next test starts to get meaty. Would I expect the write driver to transform the integer 1000 into a formatted string representation of it? Not in any real situation, so that’s an interface feature to test.
Driver Testing
After getting tests for the interface, how do we test the driver??
First, I expect the driver will not require extreme test coverage it
as would usually use a part of the system that others use (i.e. puts
or File.open
).
If you are working with a complex driver, having this partition between the interface and driver would make things easier. :-)
Depending on the driver, setting up tests can get a bit tricky as we expect the driver to work with lower level systems, which can be difficult to mock/stub/access.
Example Driver Tests
This is how I handled testing the FileDriver
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
context 'file driver' do
let(:filename) { 'filename' }
let(:driver) { FileDriver.new(filename) }
let(:file) { double('file') }
before do
allow(File).to receive(:open).and_return(file)
end
# https://stackoverflow.com/questions/4070422/rspec-how-to-test-file-operations-and-file-content
it 'opens a file' do
expect(File).to receive(:open).with('filename2', "wb")
FileDriver.new('filename2')
end
it 'writes a file' do
expect(file).to receive(:write).with("text")
driver.write('text')
end
it 'reads from a file' do
expect(File).to receive(:read).with('another_file.txt').and_return(nil)
driver.read('another_file.txt')
end
end
end
I expect the driver to only perform a select number of actions, I only test for those and edge cases.
Testing All Drivers
When testing the driver, there’s no need to set up the interface. Test only the driver implementation.
One thing to test: that the drivers support all the base functionality required for the interface. This is one way that can happen:
This works because there is a 1:1 match between interface and driver functions. If there was not a match between driver and interface functions, this might be a solution to achieve similar results:
The corresponding code in the interface:
A possible downside is that the Interface function list needs extra maintainance with any updates to interface changes.
Conclusion
Exploring testing of interfaces and drivers separately creates a natural separation that makes testing easier.
Using a driver built specifically for testing aids in focusing on interface details.
Testing the driver can be complex due to working with subsytems. On the other hand, its test coverage can be less as the subsystem is (hopefully) used by others.
One realization after all of this: Using interface-driver pattern is a great way to avoid making crazy stubs and mocks for lower level subsystems for my tests.
This is probably one of the greatest arguments against testing more and this is a solution to test more: separate concerns!