?

Log in

No account? Create an account
entries friends calendar profile My Website Previous Previous Next Next
Testing with less manual calculation - Anthony Bailey's blog
anthonybailey
anthonybailey
Testing with less manual calculation

After seeing some solutions to this Ruby coding kata on generating ASCII representations of LCD calculator numbers I thought writing the test cases could be more pleasant. Because the whole idea is to have the machine deal with the tedious task of generating the fiddly and sizeable output, I really don't like the straightforward approach that means I have to supply lots of example outputs myself, by hand.

I thought either of two alternative testing approaches might help: checking universal properties, or content regression.

Universal coverage

The first approach is that rather than give many specific examples of output, we find some generic conditions that all outputs should satisfy and then check these for many cases. For this to work well the inputs need to be simpler than the outputs, or easy to generate automatically.

A good example from a previous job involved 3D vector geometry. We had code for distances and angles, and for various transformations. An intrinsic feature of this domain is that the distance between any pair of points should be unchanged under translations, rotations and reflections, and multiplied by an appropriate factor under a scaling. Similarly the angle subtended by any three points should be unchanged under any of those four tranformations.

It was easy to check these identities for many combinations of input points and transformations, including both pseudorandom inputs and some specially chosen to provoke edge conditions e.g. zero and near-zero distances, and co-linear, perpendicular and anti-parallel vectors. Much more pleasant than manually calculating the answers for a smaller number of examples — and it gave better coverage too.

Lowest common denominators of LCD

You can find some pretty good constraints that every LCD output should satisfy; certain horizontals and verticals should contain only spaces and hyphens, others only spaces and pipes; it should have certain dimensions; and certain relationships should hold between the outputs for different sizes of the same input, and between outputs for single digits and their combinations.

Actually... once you have pinned down the basic form of each of the digits, it isn't difficult to write a complete specification of the content of each character position in the output expected for a given set of inputs. Now said spec is pretty easy to make executable: just loop over the positions and render the character that should be there. In fact it turns out to be most natural to express this procedurally without explicitly tracking the positions in the final output.

So thinking about this kind of testing informed my choice of implementation instead! An unusual variation on test-driven design.

Implementation rendered obvious

Here is the production code I ended up with.


class Lcd

  def initialize
    @cells = <<CELLS.split( '' )
 -       -   -       -   -   -   -   - 
| |   |   |   | | | |   |     | | | | |
         -   -   -   -   -       -   - 
| |   | |     |   |   | | |   | | |   |
 -       -   -       -   -       -   - 
CELLS
  end

  def render( digits, size = 2 )
    rows = [ 0, [1] * size, 2, [3] * size, 4 ].flatten
    rows.map { | row | render_row( row, digits, size ) }.join
  end

  def render_row( row, digits, size )
    cols = [ 0, [1] * size, 2 ].flatten
    digits.split( '' ).map do | digit |
      cols.map { | col | @cells[ row * 40 + digit.to_i * 4 + col ] }.join
    end.join(' ') + "\n"
  end

end

(I omitted the command-line option handling specified in the original kata — it's two uninteresting lines using the trollop gem.)

I really like the succinctness of this solution, and that the basic digit forms are directly readable in the code. It does have a bit of a primitive obsession, with the source rows and columns and the input digits all represented by plain integers, and the compositions performed by anonymous application of list operators such as join. But in this case I found the indirections and conversions that were introduced if I abstracted these away undermined the readable simplicity of the core ASCII renderer.

(I guess that nostalgia for the idioms of low-level rendering is probably why I kept the "magic numbers" expression that calculates the index to look up in @cells verbatim in the inner loop. Look, just be happy I didn't inline render_row too, OK?)

Contentful, again

Since universal properties drove the production code rather more directly than normal, although I did write some tests of that form, the main approach I used to avoid writing tedious example output myself was to instead capture what production code does, and regress against that once it looks good.

I've blogged and talked about this kind of content testing before, and implemented a Rails plug-in for view content testing. In this case distilled the approach to its unpolished essence.


def regress_render( digits, size )
  regress( "#{size}-#{digits}.content", @lcd.render( digits, size ) )
end

def regress( name, content )
  expected_content = File.open( name + '.expected' ).read rescue $!.message
  if ( expected_content != content )
    File.open( name, 'w' ).write( content )
    assert_equal( expected_content, content )
  elsif File.exists?( name )
    File.delete( name )
  end
end

So when I first put e.g. regress_render( '88', 3 ) in a test, then my current content was written to 3-88.content and I was told it didn't match "No such file or directory - 3-88.content.expected". Once I'd written enough code that the output was rendered correctly, I captured it (e.g. mv 3-88.content 3-88.content.expected.) This let me build up a solid set of regression tests without ever having to construct their content by hand — I just had to observe results and move them to being expectations once I saw they were valid.



If you found either of my new variations on this old kata diverting, please do comment with your thoughts.

Tags:

Leave a comment