Class: RuboCop::Cop::DevDoc::Test::AvoidUnitTest

Inherits:
Base
  • Object
show all
Defined in:
lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb

Overview

Prefer controller tests; flag unit/service tests (< ActiveSupport::TestCase).

Rationale

What the user sees and experiences is what matters; internal implementation does not. A controller test exercises behaviour end-to-end through the same path a user takes, so it catches the regressions that actually reach production. A unit/service test is only needed when a code path genuinely cannot be reached through a controller test (very rare) — e.g. a search-ranking detail the controller never exposes.

The danger a unit test hides: it can stay green while the feature is broken in production. It proves a method works in isolation — NOT that the real request path calls that method, in the right order, inside the transaction it needs. A model method can be flawless in a unit test while the controller calls the wrong method, skips it, or runs it outside its transaction; the unit test stays green and the feature is broken. Controller tests fail when the wiring is wrong — which is where regressions actually live. So a passing unit test is not evidence the feature works; it is false confidence about production. Reach for one only when you are sure a controller test genuinely cannot reach the path, not because it is quicker to write.

This cop flags only the literal < ActiveSupport::TestCase superclass. The blessed blackbox bases — ActionDispatch::IntegrationTest, Glib::IntegrationTest, ActionMailer::TestCase, ActiveJob::TestCase — are NOT flagged, even though they inherit from ActiveSupport::TestCase transitively.

Escape hatch

Before reaching for a unit test, assume a controller test IS possible and look harder — that conclusion is almost always premature. Behaviour that feels inherently unit-level is usually reachable end-to-end:

- Transaction rollback / "a failure mid-request": inject the failure
at a class-method chokepoint the gem/service calls (stub it to
raise), drive the real request, and assert the observable rollback
(e.g. `assert_no_difference` on the record count). Even atomicity,
which feels inherently unit-level, is reachable this way.
- "The controller wraps it in a transaction so I can't isolate the
model's own": you usually don't need to — assert the *observable*
guarantee through the real path; that is what matters in production.

When a unit test is genuinely necessary, do NOT disable this cop — use the sanctioned form instead: subclass Glib::LastResortUnitTest (the name is the policy), place the file in a unit-test directory (test/models|services|helpers/), and open it with a justification header containing the marker phrase "deliberate exception" that explains why no controller path can reach the behaviour. That form is machine-enforced by DevDoc/Test/RequireUnitTestJustification and never trips this cop, so a properly justified unit test carries zero disable directives:

# Per best practice, we focus on controller tests. This model test
# is a deliberate exception: <why no controller path exists>.
class MemberTest < Glib::LastResortUnitTest
# ...
end

Legacy < ActiveSupport::TestCase tests that predate the sanctioned form survive via project-config Excludes until migrated; new code has no reason to add one.

NOTE: The cop matches the direct superclass only. A project base (class ApplicationServiceTest < ActiveSupport::TestCase) is flagged once (justify it there); subclasses of that base are not re-flagged.

Examples:

# bad
class NoteRenderingTest < ActiveSupport::TestCase
end

# good — exercised through the controller
class NotesControllerTest < Glib::IntegrationTest
end

# good — genuinely unit-only: sanctioned base + unit directory +
# justification header (enforced by RequireUnitTestJustification)
# Per best practice, we focus on controller tests. This test is a
# deliberate exception: <why a controller test can't cover this>.
class SomeServiceTest < Glib::LastResortUnitTest
end

Constant Summary collapse

MSG =
'Prefer a controller test — unit tests are a rare exception. If a controller test ' \
'genuinely cannot cover this path, use `Glib::LastResortUnitTest` in a unit-test ' \
'directory with a justification header (see ' \
'DevDoc/Test/RequireUnitTestJustification); do not disable this cop.'.freeze

Instance Method Summary collapse

Instance Method Details

#on_class(node) ⇒ Object



92
93
94
95
96
97
98
# File 'lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb', line 92

def on_class(node)
  superclass = node.parent_class
  return unless superclass&.const_type?
  return unless superclass.const_name == 'ActiveSupport::TestCase'

  add_offense(superclass)
end