Skip to content

DRY test generators #566

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Apr 24, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 21 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,54 +114,48 @@ the exercise, which lives in the x-common repository.
This change will need to be submitted as a pull request to the x-common repository. This pull
request needs to be merged before you can regenerate the exercise.

Changes that don't have to do directly with the test inputs and outputs, will either need to be
made to `exercises/$PROBLEM/example.tt` or `lib/$PROBLEM_cases.rb`. Then you can regenerate the
exercise with `bin/generate $PROBLEM`.
Changes that don't have to do directly with the test inputs and outputs, will
most likely be made to `lib/$PROBLEM_cases.rb` but may also be made to
`exercises/$PROBLEM/example.tt`. Then you can regenerate the exercise with
`bin/generate $PROBLEM`.

#### Implementing a Generator

You will need to implement three files to create a generator:
You will need to implement two files and a directory to create a generator:

1. `exercises/$PROBLEM/example.tt` - the Erb template for the test file, `$PROBLEM_test.rb`.
1. `exercises/$PROBLEM/.meta/.version` - used to keep track of the version of the test files as the data changes.
1. `lib/$PROBLEM_cases.rb` - the logic for turning the data into tests.
1. `exercises/$PROBLEM/example.tt` - the Erb template for the test file, `$PROBLEM_test.rb`.
1. `exercises/$PROBLEM/.meta/` - metadata directory, currently contains version file

You will not need to touch the top-level script, `bin/generate`.

The `bin/generate` command relies on some common logic implemented in `lib/generator.rb`.
You probably won't need to touch that, either.

The `lib/$PROBLEM_cases.rb` file should contain a small class that wraps the JSON for a single test case:
`lib/$PROBLEM_cases.rb` contains a derived class of `ExerciseCase` (in `lib/generator/exercise_cases.rb`)
which wraps the JSON for a single test case. The default version looks something like this:

```
require 'exercise_cases'

class ProblemNameCase < OpenStruct
def name
'test_%s' % description.gsub(/[ -]/, '_')
end
class ProblemNameCase < ExerciseCase

def workload
# Example workload:
"assert #{expected.inspect}, Problem.call(#{input.inspect})"
"#{assert} Problem.call(#{input.inspect})"
end

def skipped
index.zero? ? '# skip' : 'skip'
end
end
```

Instead of `ProblemName` use the name of the actual problem. This is important, since
Instead of `ProblemName` use the CamelCased name of the actual problem. This is important, since
the generator script will infer the name of the class from the argument that is passed.

This class must implement the following methods:

- `name` - Returns the name of the test (i.e `test_one_equals_one`)
- `workload` - Returns the main syntax for the test. This includes the assertion and any setup required for the test. This will vary depending on the test generator and its underlying implementation
- `skipped` - Returns skip syntax (i.e. `skip` or `# skip`)
This class must provide the methods used by `example.tt`. The base class provides methods
for the default template for everything except `workload`.

Beyond that, you can implement any helper methods that you need.
`workload` generates the code for the body of a test, including the assertion
and any setup required. The base class provides a variety of assertion and
helper methods. Beyond that, you can implement any helper methods that you need
as private methods in your derived class.

Below this class, implement a small loop that will generate all the test cases by reading the
`canonical-data.json` file, and looping through the test cases.
Expand Down Expand Up @@ -198,7 +192,8 @@ end

Finally, you need to create a text template, `example.tt`, as the bases for the test suite.

Start with the following boilerplate, and adjust as necessary:
Start with the following boilerplate, and adjust as necessary. Remember, however, to strive
to keep logic out of views.

```
#!/usr/bin/env ruby
Expand All @@ -216,6 +211,7 @@ class ProblemNameTest < Minitest::Test

<% end %>
<%= IO.read(XRUBY_LIB + '/bookkeeping.md') %>

def test_bookkeeping
skip
assert_equal <%= version %>, BookKeeping::VERSION
Expand Down
10 changes: 6 additions & 4 deletions exercises/hamming/example.tt
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@ require 'minitest/autorun'
require_relative 'hamming'

# Common test data version: <%= abbreviated_commit_hash %>
class HammingTest < Minitest::Test<% test_cases.each do |test_case| %>
class HammingTest < Minitest::Test
<% test_cases.each do |test_case| %>
def <%= test_case.name %>
<%= test_case.skipped %><% if test_case.raises_error? %>
assert_raises(ArgumentError) { <%= test_case.workload %> }<% else %>
assert_equal <%= test_case.expected %>, <%= test_case.workload %><% end %>
<%= test_case.skipped %>
<%= test_case.workload %>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice.

end

<% end %>
<%= IO.read(XRUBY_LIB + '/bookkeeping.md') %>

def test_bookkeeping
skip
assert_equal <%= version %>, BookKeeping::VERSION
Expand Down
9 changes: 4 additions & 5 deletions exercises/hamming/hamming_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@
require 'minitest/autorun'
require_relative 'hamming'

# Test data version:
# deb225e Implement canonical dataset for scrabble-score problem (#255)

# Common test data version: bb56dc7
class HammingTest < Minitest::Test
def test_identical_strands
# skip
Expand Down Expand Up @@ -88,8 +86,9 @@ def test_disallow_second_strand_longer
# not your solution.
#
# Define a constant named VERSION inside of the top level BookKeeping
# module.
# In your file, it will look like this:
# module, which may be placed near the end of your file.
#
# In your file, it will look like this:
#
# module BookKeeping
# VERSION = 1 # Where the version number matches the one in the test.
Expand Down
10 changes: 5 additions & 5 deletions exercises/luhn/luhn_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,27 @@ def test_single_digit_strings_can_not_be_valid
refute Luhn.valid?("1")
end

def test_A_single_zero_is_invalid
def test_a_single_zero_is_invalid
skip
refute Luhn.valid?("0")
end

def test_a_simple_valid_SIN_that_remains_valid_if_reversed
def test_a_simple_valid_sin_that_remains_valid_if_reversed
skip
assert Luhn.valid?("059")
end

def test_a_simple_valid_SIN_that_becomes_invalid_if_reversed
def test_a_simple_valid_sin_that_becomes_invalid_if_reversed
skip
assert Luhn.valid?("59")
end

def test_a_valid_Canadian_SIN
def test_a_valid_canadian_sin
skip
assert Luhn.valid?("055 444 285")
end

def test_invalid_Canadian_SIN
def test_invalid_canadian_sin
skip
refute Luhn.valid?("055 444 286")
end
Expand Down
2 changes: 1 addition & 1 deletion exercises/ocr-numbers/ocr_numbers_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ def test_recognizes_string_of_decimal_numbers
assert_equal "1234567890", OcrNumbers.convert(" _ _ _ _ _ _ _ _ \n | _| _||_||_ |_ ||_||_|| |\n ||_ _| | _||_| ||_| _||_|\n ")
end

def test_numbers_separated_by_empty_lines_are_recognized__lines_are_joined_by_commas_
def test_numbers_separated_by_empty_lines_are_recognized_lines_are_joined_by_commas
skip
assert_equal "123,456,789", OcrNumbers.convert(" _ _ \n | _| _|\n ||_ _|\n \n _ _ \n|_||_ |_ \n | _||_|\n \n _ _ _ \n ||_||_|\n ||_| _|\n ")
end
Expand Down
43 changes: 43 additions & 0 deletions lib/generator/exercise_cases.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
require 'ostruct'
require 'json'

class ExerciseCase < OpenStruct
using Generator::Underscore

def name
'test_%s' % description.underscore
end

def skipped
index.zero? ? '# skip' : 'skip'
end

protected

# used in workload, for example, as
# "#{assert} Luhn.valid?(#{input.inspect})"
def assert
expected ? 'assert' : 'refute'
end

# used in workload, for example, as
# assert_equal { "PigLatin.translate(#{input.inspect})" }
def assert_equal
"assert_equal #{expected.inspect}, #{yield}"
end

# used in workload, for example, as
# if raises_error?
# assert_raises(ArgumentError) { test_case }
# else
# assert_equal { test_case }
# end

def raises_error?
expected.to_i == -1
end

def assert_raises(error)
"assert_raises(#{error}) { #{yield} }"
end
end
9 changes: 9 additions & 0 deletions lib/generator/underscore.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module Generator
module Underscore
refine String do
def underscore
downcase.gsub(/[- ]/, '_').gsub(/[^\w?]/, '')
end
end
end
end
23 changes: 9 additions & 14 deletions lib/hamming_cases.rb
Original file line number Diff line number Diff line change
@@ -1,21 +1,16 @@
require 'exercise_cases'

class HammingCase < OpenStruct
def name
'test_%s' % description.gsub(/[ -]/, '_')
end

class HammingCase < ExerciseCase
def workload
"Hamming.compute('#{strand1}', '#{strand2}')"
if raises_error?
assert_raises(ArgumentError) { test_case }
else
assert_equal { test_case }
end
end

def raises_error?
expected.to_i == -1
end
private

def skipped
index.zero? && '# skip' ||
'skip'
def test_case
"Hamming.compute('#{strand1}', '#{strand2}')"
end
end

Expand Down
20 changes: 2 additions & 18 deletions lib/luhn_cases.rb
Original file line number Diff line number Diff line change
@@ -1,22 +1,6 @@
require 'exercise_cases'

class LuhnCase < OpenStruct
def name
'test_%s' % description.tr('- ', '__')
end

class LuhnCase < ExerciseCase
def workload
%Q(#{assertion} Luhn.valid?(#{input.inspect}))
end

def skipped
index.zero? ? '# skip' : 'skip'
end

private

def assertion
expected ? 'assert' : 'refute'
"#{assert} Luhn.valid?(#{input.inspect})"
end
end

Expand Down
18 changes: 4 additions & 14 deletions lib/ocr_numbers_cases.rb
Original file line number Diff line number Diff line change
@@ -1,22 +1,12 @@
require 'exercise_cases'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should have been another separate commit.


class OcrNumbersCase < OpenStruct
def name
'test_%s' % description.downcase.tr('- .', '_')
end

class OcrNumbersCase < ExerciseCase
def workload
if expected == -1
"assert_raises(ArgumentError) { #{test_case} }"
if raises_error?
assert_raises(ArgumentError) { test_case }
else
"assert_equal #{expected.inspect}, #{test_case}"
assert_equal { test_case }
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely a better place for this.

end

def skipped
index.zero? ? '# skip' : 'skip'
end

private

def test_case
Expand Down
14 changes: 2 additions & 12 deletions lib/pig_latin_cases.rb
Original file line number Diff line number Diff line change
@@ -1,16 +1,6 @@
require 'exercise_cases'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not entirely sure I agree with removing this line.
Sure require_all is awesome, but there's only one dependency and it's nice to be able to see where ExerciseCase comes from.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And it simply doesn't break anything... if that dependency goes away, no problem, it is a documentation value at this point.


class PigLatinCase < OpenStruct
def name
'test_%s' % description.tr('- ', '__')
end

class PigLatinCase < ExerciseCase
def workload
%Q(assert_equal #{expected.inspect}, PigLatin.translate(#{input.inspect}))
end

def skipped
index.zero? ? '# skip' : 'skip'
assert_equal { "PigLatin.translate(#{input.inspect})" }
end
end

Expand Down
23 changes: 23 additions & 0 deletions test/generator/underscore_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
require_relative '../test_helper'

module Generator
class UnderscoreTest < MiniTest::Test
using Underscore

# at present, we're downcasing everything, including TLAs
def test_mixed_case
assert_equal 'A string with TLA'.underscore, 'a_string_with_tla'
end

def test_hyphenated_text
assert_equal 'large distance in off-by-one strand'.underscore, 'large_distance_in_off_by_one_strand'
end

def test_question_mark
assert_equal(
'Unreadable but correctly sized inputs return ?'.underscore,
'unreadable_but_correctly_sized_inputs_return_?'
)
end
end
end