Class: RuboCop::Cop::DevDoc::Test::NoUnitIdiomsInIntegrationTests

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

Overview

Integration tests must be BOUNDARY tests: exercise one entry point (an HTTP request, or a job for cron-driven flows with no HTTP surface) and assert only its observable outputs — response status and body, database state, delivered/enqueued mail. Unit-test idioms under the integration base class are dishonest by construction: the base class is a declaration of WHAT KIND of test this is, and the runtime/static lints trust that declaration.

What flags, and why

  • @controller, assigns, instance_variable_get: reaching into request-internal state the boundary is supposed to hide.
  • FooService.new / FooPolicy.new (any suffix in UnitClassSuffixes): instantiating app-layer objects to test or query them directly. When used to COMPUTE an expected value, this is a circular assertion — the test asks production code what to expect, so a bug in that code passes its own test. Pin literals or fixture facts instead.
  • FooMailer.with(...) and any other direct mailer invocation: manufacture mail through the flow that actually sends it, then read deliveries.
  • include FooHelper: importing production helpers to compute expectations — circular, same as above (*TestHelper modules are exempt).
  • Minitest::Mock, .stub(...), require 'minitest/mock': mock/stub machinery is unit-test tooling by definition.

What deliberately does NOT flag

  • Model reads (Organization.last, Member.find_by!, counts, attribute readers): the database is an OUTBOUND edge of the boundary — asserting persisted state is the point — and fixture lookup is setup.
  • Job classes (FooJob.new, perform_now, perform_enqueued_jobs): jobs are an INBOUND edge — the entry point for cron flows.

The honesty rule

If the property cannot be observed at any boundary, the test is a unit test. Declare it: Glib::LastResortUnitTest in an exempt directory with a justification header. An observed evasion (2026-07, a guard-aware agent bolting a GET onto direct policy assertions) is why this cop assumes disguise rather than accident. Do not disable this cop.

Constant Summary collapse

MSG =
'`%<ref>s` is a unit-test idiom inside an integration test. A boundary test ' \
'exercises one entry point (HTTP request, or a job for cron flows) and asserts ' \
'only its observable outputs (status, body, DB state, mails). Computing an ' \
'expectation with production code is a CIRCULAR assertion (the code grades its ' \
'own homework); instantiating app objects or reaching into the controller is ' \
'unit testing in disguise, and a bolted-on request does not change what it is. ' \
'Be honest about what this test is: assert through the boundary, or declare a ' \
'real unit test (Glib::LastResortUnitTest + exempt directory + justification ' \
'header). Do not disable this cop.'.freeze
CONTROLLER_INTERNALS =
%i[assigns instance_variable_get].freeze
HELPER_EXEMPT =
/TestHelper\z|MailerTester\z/
MOCK_REQUIRES =
['minitest/mock'].freeze
DEFAULT_UNIT_CLASS_SUFFIXES =

Baseline lives in code (config lists are droppable; see CurrentUserBranching's DEFAULT_ALLOWED_METHODS for the incident that taught this). The yml key ADDS suffixes, it cannot remove these.

%w[Service Policy Form Presenter Serializer Decorator].freeze

Instance Method Summary collapse

Instance Method Details

#on_ivar(node) ⇒ Object



67
68
69
70
71
# File 'lib/rubocop/cop/dev_doc/test/no_unit_idioms_in_integration_tests.rb', line 67

def on_ivar(node)
  return unless node.children.first == :@controller

  add_offense(node, message: format(MSG, ref: '@controller'))
end

#on_send(node) ⇒ Object



73
74
75
76
77
78
# File 'lib/rubocop/cop/dev_doc/test/no_unit_idioms_in_integration_tests.rb', line 73

def on_send(node)
  ref = offense_ref(node)
  return unless ref

  add_offense(node, message: format(MSG, ref: ref))
end