Class: RuboCop::Cop::DevDoc::Test::RequireUnitTestJustification
- Inherits:
-
Base
- Object
- Base
- RuboCop::Cop::DevDoc::Test::RequireUnitTestJustification
- Includes:
- JustificationHeader, TestFilePlacement
- Defined in:
- lib/rubocop/cop/dev_doc/test/require_unit_test_justification.rb
Overview
Classifies every test file (test/**/*_test.rb) into one of three
buckets, so no directory placement can dodge test-type enforcement:
- Exempt (
ExemptDirectories, default: controllers, integration, jobs, mailers, channels, system, linters, tasks) — covered by other enforcement (test/controllers/ is guaranteed HTTP-driven at runtime by DevDoc::Test::Lints::HttpDrivenControllerTests) or accepted integration-shaped homes. Even here, inheriting a unit base class (UnitBaseClasses, defaultGlib::LastResortUnitTest) is an offense: a unit base is only legitimate in a unit-test directory, so its appearance in an exempt directory means a unit test is parked where the justification-header rule (and, for test/controllers/, the runtime HTTP lint — which only reaches integration-base descendants) cannot see it. The non-HTTP side-effect base (NonHttpBaseClasses, defaultGlib::NonHttpIntegrationTest) is confined the same way to its own directories (NonHttpDirectories, default: jobs, mailers, channels): full-stack tests whose contract is a side effect (a job's record mutations, a mailer's body) rather than an HTTP response. Outside those directories it is an evasion vector — in test/controllers/ it would dodge the runtime HTTP lint exactly like a unit base, and in a unit directory it would launder a unit test past the truthful-declaration rule. Inside them, a class on the non-HTTP base carries the same justification-header contract as a unit directory (it consciously opted out of HTTP), while classes on the integration base stay header-free — job tests legitimately mix HTTP assertions with side-effect assertions. - Unit-test directories (
UnitTestDirectories, default: models, services, helpers) — allowed only with a justification header: the leading comments (before the first class/module) must contain the marker phrase (default "deliberate exception") explaining why a controller test can't cover this, AND a wiring line (WiringPhrase, default "wiring:") naming the request test that still covers the HTTP path this test's subject participates in — or why no runtime HTTP path exists, naming the actual entry point. The justification must state the concrete mechanism (who calls save!, where the data comes from); stock phrases ("the controller merely surfaces the model's errors") do not count, and a header that misstates the mechanism is worse than none — verify the claim by tracing the write path before writing it. Base classes are allowlisted here (RequireUnitBase, default true): a *Test class must subclass aUnitBaseClassesmember — plain ActiveSupport::TestCase is rejected as an unexamined default, so the confession base is structural, not optional. Inline helpers/doubles (non-*Test names) are not policed. - Anything else — flagged as an unrecognized test directory. Adding a new test home is a reviewed decision made in this cop's config, not something a generated test can do implicitly.
Rationale
Best practice strongly prefers controller tests (see AvoidUnitTest):
unit tests stay green while the production wiring is broken, so a
passing unit test is false confidence about production. The
justification bar is deliberately high — before writing the header,
assume a controller test IS possible and look harder, because that
conclusion is almost always premature: AvoidUnitTest's docs list the
patterns (inject a failure at a class-method chokepoint the real
request hits, assert the observable guarantee through the real path)
that make seemingly unit-only behaviour reachable end-to-end. Observed
evasions by AI agents include ambiguous file names, unit tests mixed
into controller-test files, inheriting the integration base class so
the < ActiveSupport::TestCase heuristic never fires — and, once
per-directory enforcement exists, inventing new directories outside
it. The three-bucket total classification closes the last of those:
every test file is either behaviorally enforced, explicitly justified,
or flagged.
❌ test/models/member_test.rb with no header
class MemberTest < Glib::IntegrationTest
❌ test/controllers/member_test.rb — unit base hiding in an exempt dir
class MemberTest < Glib::LastResortUnitTest
✔ justified
# Per best practice, we focus on controller tests. This model test
# is a deliberate exception: the safely_* guards raise on ANY direct
# call, so by design no controller path can reach them.
# Wiring: none — the guards are dead-man switches with no runtime
# caller; member_soft_deletion_test covers the soft-delete flow.
class MemberTest < Glib::LastResortUnitTest
Constant Summary collapse
- MSG_UNRECOGNIZED =
'Test file in unrecognized directory `test/%<dir>s/`. Unit tests hide in ' \ 'uncategorized directories: move this file to a recognized home ' \ '(%<known>s), or add the directory to this cop\'s configuration — a ' \ 'reviewed decision, not an implicit one.'.freeze
- MSG_BASE =
'`%<base>s` is an integration base class — a unit test inheriting it lies about ' \ 'its type and gains HTTP helpers it must never use. Subclass a unit-test base ' \ '(e.g. `Glib::LastResortUnitTest`) so the declaration is truthful and the verbs are ' \ 'structurally absent.'.freeze
- MSG_UNIT_BASE =
'`%<base>s` belongs only in a unit-test directory (%<unit_dirs>s), where ' \ 'the justification header is enforced. A unit test in `test/%<dir>s/` is ' \ 'hiding from that enforcement — move it there and justify it, or write a ' \ 'real %<dir>s test.'.freeze
- MSG_UNIT_BASE_REQUIRED =
'`%<base>s` is not a sanctioned unit-test base. A justified unit ' \ 'test must subclass one of: %<unit_bases>s — the name is the ' \ 'policy (a conscious last resort) and base-class-keyed tooling ' \ 'relies on it; plain ActiveSupport::TestCase reads as an ' \ 'unexamined default. Extend UnitBaseClasses in this cop\'s ' \ 'config if your project has another sanctioned unit base.'.freeze
- MSG_NON_HTTP_BASE =
'`%<base>s` is the non-HTTP side-effect base — it belongs only in ' \ '%<non_http_dirs>s. In `test/%<dir>s/` it evades that directory\'s ' \ 'enforcement (the runtime HTTP lint reaches only integration-base ' \ 'descendants; unit directories require a unit base under a ' \ 'justification header) — move the test, or subclass the base this ' \ 'directory is enforced through.'.freeze
- FORBIDDEN_BASES =
%w[ActionDispatch::IntegrationTest Glib::IntegrationTest].freeze
- EXEMPT_DIRS =
%w[controllers integration jobs mailers channels system linters tasks].freeze
- NON_HTTP_DIRS =
%w[jobs mailers channels].freeze
Constants included from JustificationHeader
JustificationHeader::MSG_JUSTIFY, JustificationHeader::MSG_JUSTIFY_WIRING, JustificationHeader::MSG_WIRING
Instance Method Summary collapse
Instance Method Details
#on_new_investigation ⇒ Object
135 136 137 138 139 140 141 142 |
# File 'lib/rubocop/cop/dev_doc/test/require_unit_test_justification.rb', line 135 def on_new_investigation return if processed_source.blank? dir = test_subdirectory return if dir.nil? # not under a test/ tree at all enforce_directory_rules(dir) end |