Red Green Repeat Adventures of a Spec Driven Junkie

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:

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:

class TestDriver
  def write(text)
    text
  end
end

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:

context 'supports all the functions' do
  let(:drivers) { [FileDriver, ScreenDriver, HTMLDriver] }
  let(:interface_methods) { UserInterface.instance_methods(false) }
  it 'checks the driver supports the instance methods' do
    drivers.each do |driver|
      expect(driver.instance_methods(false) & interface_methods == interface_methods).to eq(true)
    end
  end
end

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:

 it 'checks the driver function list' do
    required_interface_functions = UserInterface::REQUIRED_DRIVER_FUNCTIONS.sort
   drivers.each do |driver|
      expect(driver.instance_methods(false).sort & required_interface_functions == required_interface_functions).to eq(true)
    end
 end

The corresponding code in the interface:

class UserInterface
  REQUIRED_DRIVER_FUNCTIONS = %i(write read close)
  # ...
end

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!