Class: RuboCop::Cop::DevDoc::Test::RequireUnitTestJustification

Inherits:
Base
  • Object
show all
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:

  1. 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, default Glib::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, default Glib::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.
  2. 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 a UnitBaseClasses member — 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.
  3. 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

Examples:

# bad — test/units/quiet_test.rb (unrecognized directory)

# bad — test/models/member_test.rb without the marker phrase

# bad — test/controllers/member_test.rb inheriting Glib::LastResortUnitTest

# good — test/models/member_test.rb whose leading comments contain
# "deliberate exception" and a "Wiring:" line, and explain why no
# controller path exists

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_investigationObject



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