Why test?
- Improves development quality and speed - needed state is usually faster and easier to set up and manipulate in test environment.
- Establishes confidence that things work as needed and will continue to do so.
- Serves as documentation.
- Drives better design - if it's hard to test, it's poorly designed.
What to test?
Follow J.B. Rainsberger's idea of "isolated tests" - test pieces of source code in a single file, usually public methods.
Do contract testing - stub out calls to other class methods, but assert that the calls happen with correct parameters and that code under test correctly handles the mocked return value. This way code close to input/output (for example controllers) need not run through the whole app stack.
# sample action
def update
record = find_user!
return render_record_missing_error unless record
result = SomeService.call(
user: record,
company: current_company,
options: safe_params.to_hash,
)
if result.success?
render(json: result, status: :ok)
else
render(json: result, status: :unprocessable_entity)
end
end
Focusing on only testing the code written in the file worked on tells us that SomeService.call
should be treated as a black box and stubbed out. We do not care what side-effects it produces, only that code under test can use its return value properly.
Furthermore, we have three ways out of this method:
- early return due to missing user and two proper results
- a success
- a failure
Covering these scenarios should reach 100% coverage without excessive specs.
# sample good spec
describe SomeController, "update" do
context "when user can not be found" do
it { responds_with_record_missing }
end
context "when SomeService call returns a success outcome" do
before do
allow(SomeService).to receive(:call).and_return(success_outcome)
end
it do
responds_with_success_json
expect(SomeService).to have_received(:call).with(
user: user,
company: company,
options: {some: param},
).once
end
end
context "when SomeService call returns a failure outcome" do
before do
allow(SomeService).to receive(:call).and_return(failure_outcome)
end
it { responds_with_failure_json }
end
end