PrologMinitestMatchers
This is an evolving collection of MiniTest and MiniTest::Spec custom matchers which we have found useful in our work. As we have built up our applications, we've seen the disparity between tests and assertions grow; often to a differential of 30% or even more. While some standard MiniTest::Spec expectations involve multiple assertions, we can police our own.
Installation
Add this line to your application's Gemfile:
gem 'prolog_minitest_matchers'
And then execute:
$ bundle
Or install it yourself as:
$ gem install prolog_minitest_matchers
Usage
As of Gem Release 0.3.0, this Gem defines three MiniTest::Spec expectations (and thus three MiniTest asserters) that can be used in your MiniTest code:
-
must_require_dry_struct_attribute
(assert_requires_dry_struct_attribute
); -
must_require_initialize_parameter
(assert_requires_initialize_parameter
); and -
must_require_static_call_param
(assert_requires_static_call_param
).
In each case, the expectation (through the implementing asserter) will verify that the parameter expected to cause a failure when omitted must exist in the supplied set of full parameters.
must_require_dry_struct_attribute
Implemented by asserter MiniTest::Assertions::AssertRequiresDryStructAttribute
.
Suppose that you have code such as
# in foo.rb
class Foo < Dry::Struct
attribute :foo, Types::Strict::String
attribute :bar, Types::Coercible.Int
# ...
end
and test code such as
# in foo_test.rb
describe 'Foo' do
describe 'initialisation' do
describe 'requires parameters for' do
let(:params) { { foo: 'some foo', bar: 42 } }
it 'foo' do
params.delete :foo
error = expect { Foo.new params }.must_raise KeyError
expect(error.message).must_equal "No key :foo in #{params.inspect}!"
end
it 'bar' do
# ...
end
end # describe 'requires parameters for'
end # describe 'initialisation'
# ... other tests
end
That parameter test has two expectations: first, that a KeyError
will be raised; and second, that that error's message will be as expected. Instead, how about this:
# in foo_test.rb
describe 'Foo' do
describe 'initialisation requires parameters for' do
let(:params) { { foo: 'some foo', bar: 42 } }
it ':foo' do
expect(Foo).must_require_dry_struct_attribute params, :foo
end
it ':bar' do
expect(Foo).must_require_dry_struct_attribute params, :bar
end
end # describe 'initialisation requires parameters for'
# ... other tests
end
Somewhat shorter, yes; more importantly, two much more intention-revealing expectations that are counted as one assertion each. If you ascribe to the widely-used recommendation that each test (it
block in MiniTest::Spec usage) should test one thing, this will have you wondering "how come my tests and assertions don't match up very well" just that little bit less.
must_require_initialize_parameter
Implemented by asserter MiniTest::Assertions::AssertRequiresInitializeParameter
.
Again, suppose that you have code such as
# in foo.rb
class Foo
def initialize(foo:, bar:)
# ...
end
# ...
end
and test code such as
# in foo_test.rb
describe 'Foo' do
describe 'initialisation' do
describe 'requires parameters for' do
let(:params) { { foo: 'some foo', bar: 'some bar' } }
it 'foo' do
params.delete :foo
error = expect { Foo.new params }.must_raise ArgumentError
expect(error.message).must_equal 'missing keyword: foo'
end
it 'bar' do
# ...
end
end
# ... other initialisation tests
end
# ... other tests
end
As before, that parameter test has two expectations: first, that an ArgumentError
will be raised; and second, that that error's message will be as expected. Instead, we can now use this:
# in foo_test.rb
describe 'Foo' do
describe 'initialisation' do
describe 'requires parameters for' do
let(:params) { { foo: 'some foo', bar: 'some bar' } }
it 'foo' do
expect(Foo).must_require_initialize_parameter params, :foo
end
it 'bar' do
expect(Foo).must_require_initialize_parameter params, :bar
end
end
# ... other initialisation tests
end
# ... other tests
end
Again, two much more intention-revealing expectations that are counted as one assertion each.
must_require_static_call_param
Implemented by asserter MiniTest::Assertions::AssertRequiresInitializeParameter
.
This is useful, not for initialisation in the traditional sense, but for service objects implementing a class-level .call
interface that takes one or more named parameters. You might have code that looks like:
# in foo.rb
class Foo
def self.call(foo:, bar:)
Foo.new(foo, bar).call
end
def call
# ...
end
protected
def initialize(foo, bar)
@foo = massage_initial_foo_with foo
@bar = massage_initial_bar_with bar
self
end
private
attr_reader :bar, :foo
# ...
end
and test code such as
# in foo_test.rb
describe 'Foo' do
describe 'initialisation' do
describe 'requires parameters for' do
let(:params) { { foo: 'some foo', bar: 'some bar' } }
it 'foo' do
params.delete :foo
error = expect { Foo.new params }.must_raise KeyError
expect(error.message).must_equal "No key :foo in #{params.inspect}!"
end
it 'bar' do
# ...
end
end
# ... other initialisation tests
end
# ... other tests
end
As before, that parameter test has two expectations: first, that a KeyError
will be raised; and second, that that error's message will be as expected. We can use this instead:
# in foo_test.rb
describe 'Foo' do
describe 'initialisation' do
describe 'requires parameters for' do
let(:params) { { foo: 'some foo', bar: 'some bar' } }
it 'foo' do
expect(Foo).must_require_static_call_param params, :foo
end
it 'bar' do
expect(Foo).must_require_static_call_param params, :bar
end
end
# ... other initialisation tests
end
# ... other tests
end
Once again, two much more intention-revealing expectations that are counted as one assertion each.
Notes on Implementation
Attentive readers may note the strong similarity between these three expectations. They would be right; although the use cases differ in important ways, the implementation details that set each apart from the others have been reduced to a minimum, as inspecting the code itself will reveal. See some room for improvement? Great! Open an issue and let's talk about it!
Important: This Gem no longer supports versions of the dry-types
Gem prior to Version 0.8.0, when the dry-struct
Gem was extracted and published separately. The class names for structs and value objects have changed (from Dry::Types::Struct
and Dry::Types::Value
to Dry::Struct
and Dry::Struct::Value
respectively), and thus version 0.4.0 of this Gem represents a breaking change from what was published before.
Errata
Reversing MiniTest::Spec expectations does not work (Issue #1)
Ordinarily, MiniTest::Spec matchers provide reversible expectations; that is, expectations can be positive (must_
) or negative (won't_
). For a trivial example,
expect(2 + 2).must_equal 4
expect(2 + 2).wont_equal 5
The same "asserter" code is being exercised, and fails if the asserted condition is false (for must_equal
) or true (for wont_equal
).
None of the matchers included as part of Gem Release 0.3.0 (must_require_dry_struct_attribute
, must_require_initialize_parameter
, and must_require_static_call_param
) are reversible in this way. Given the expected use cases of these matchers, this has been judged to be acceptable; the issue has been left open but labelled wontfix
. (PRs welcome).
Development
After checking out the repo, run bin/setup
to install dependencies (which as of now must already be installed on your local system). Then, run bin/rake test
to run the tests, or bin/rake
to run tests and, if tests are successful, further static-analysis tools (RuboCop, Flay, Flog, and Reek).
To install your build of this Gem onto your local machine, run bin/rake install
. We recommend that you uninstall any previously-installed "official" Gem to increase your confidence that your tests are running against your build.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/TheProlog/prolog_minitest_matchers. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.
Process
If you wish to submit a new feature, such as a new matcher, to the Gem, please open an issue to discuss your idea with the maintainer and other interested community members. Issue threads are a great place to thrash out the details of what you're trying to accomplish and how your work would affect other code and/or community members. If you need help with something, or aren't sure how to choose between different ideas to accomplish some detail of what you're setting out to do, this is the place to discuss that. There is no such thing as a stupid question that you don't know the answer to (once you've researched in your search engine of choice, of course; please do respect people's time and attention).
The processes for proposing a new feature or a fix to an open bug-report issue are very similar:
- Make sure that you have forked this Gem's repository on GitHub to your own GitHub account. (If you don't yet have a GitHub account, join; it's free.)
- If you're proposing a new feature, open an issue as suggested above. If you're addressing an existing issue, thanks; you don't need to open a new one.
- Clone your copy of the repo to your local development system.
- Create a new Git branch for your work. It's best to give it a reasonably short name that's suggestive of what you're specifically trying to accomplish.
- If you're adding a new feature, such as a new matcher, consider that
dry-struct-attribute
was a more useful branch name for themust_require_static_call_param
matcher than, say,my-new-matcher
for (hopefully) obvious reasons. - If you're adding a fix for an existing issue, say Issue #4172, then a branch name of
issue-4172
is probably perfect. -
Do not work on your copy of the
master
branch! Any pull request (see below) that you later submit for changes you've made onmaster
will be rejected, and you will be asked to submit your proposed changes on a branch that branches from a commit on the upstreammaster
branch.
- If you're adding a new feature, such as a new matcher, consider that
- Now write great (tests and) code!
- As soon as you have something to show, even if it's not complete yet (but it passes what tests you have), push your branch to your forked repo on GitHub and open a new pull request ("PR") for your branch compared to
master
on the upstream repository. That lets the maintainer and other community members review your code and tests, comment, help out, and so on. - Continuing with your pull requests, it's usually better if you make small, incremental changes in each commit in a sequence. We (endeavour to) practice behaviour-driven development: write tests for the simplest thing that could possibly work; see the tests fail; then make them pass, commit, and go on to the next simplest thing. Don't get hung up on lots of refactoring until you have code that does everything you want it to do; once you have a legitimately complete green bar, that's the time to apply SOLID principles and patterns to DRY things up. Better to have (temporary) duplication than choose the wrong abstraction.
Notes on Contributing
Don't be discouraged if it takes several commits to complete your work and then several more to get everybody agreeing that it's complete and well done. ("Useful" and "worth adding" should have been settled at the issue stage, before you started working on your PR.) That's becauseā¦
When pull requests are merged into the master
branch, they are squashed so that all changes are applied to master
in a single commit. This means that, even if you have a dozen or more commits in your PR where you've been very incremental, and even changed direction once or twice, what matters is the final result; not what it took to get there.
Once your PR has been merged, it's a good idea to pull the upstream master
branch to your development system (git pull upstream master
) and then push it to your fork (git push origin master
). (What? You don't have an upstream
remote as shown by git remote -v
? Run the command git remote add upstream https://github.com/TheProlog/prolog_minitest_matchers.git
from your local development directory, and now you do.)
License
The Gem is available as open source under the terms of the MIT License.