Implementation-Coupled¶
| Slug | Severity | Detection Scope | Protects |
|---|---|---|---|
implementation-coupled |
High | per-test | Maintainable, Independent of implementation |
Summary¶
The test reaches through a public type into a private method, internal field, or undocumented implementation shape. Renaming or refactoring an internal detail breaks the test even when observable behavior is unchanged.
Aliases¶
- "implementation coupled"
- "private method tests"
- "private field access"
- "underscore-prefixed access"
- "reaches into internals"
- "tests private API"
- "VisibleForTesting"
- "internal accessors"
Description¶
Distinct from over-specified-mock — that's about over-asserting interactions; this is about reaching into state or visibility.
The semantic judgment: identify whether the accessed field or method is part of the SUT's stable public API. For third-party libraries, this often requires reading the library's published docs.
Signals¶
Direct syntactic signals (language-dependent):
(x as any).someProp,x['_private'],x.#field(TS/JS).described_class.send(:private_method, ...)(Ruby)._private_method(or_PrivateMethod(in the test body (Python convention).@VisibleForTesting/internalaccessors (Kotlin, Java, C#).- Accessing lowercase fields from an adjacent test file in a different Go package via
unsafeor test-package-bridge tricks.
Semantic signals:
- Accessors whose names start with
_or match known-private conventions. - For third-party libraries: the accessed field doesn't appear in exported types or public docs.
- Python:
sut._internal_dict['some_key']instead of a public getter. - Tests calling private helpers via
(sut as any).helper()and asserting on the helper's return directly.
False-positive guards¶
The "reaches into internals" signals are language-conditional and over-trigger when transplanted between ecosystems:
- "Private" is a per-language convention. The signals (
_prefix,send(:method),(x as any), lowercase-Go) only mean "private" in the conventions where they do. Python's_helperis a naming convention — same-module test access is conventional and explicitly permitted in many codebases. Rust's#[cfg(test)] mod testsis built to read private fields on purpose. Apply each language's convention before flagging; the per-ecosystem pointers in the Polyglot notes below are the source of truth, not a transplant from TS's strictprivate. - Sanctioned visibility relaxation for testing. Some languages provide a deliberate escape hatch for test-time access: Java/Guava
@VisibleForTesting, .NET's[InternalsVisibleTo], Kotlininternal, Go's same-package_test.gofiles, Rubysendaccompanied by an annotated allow comment. When a test uses the sanctioned mechanism, the access was an intentional contract decision by the SUT's author, not a coupling violation. Flag access that bypasses the sanctioned mechanism (forced reflection in Java where@VisibleForTestingwould have sufficed;(x as any)cast in TS where the property could have been declaredprotectedfor the test subclass); do not flag the use of the sanctioned mechanism itself.
Prescribed Fix¶
- Drive the library's public API instead of reaching for internals (
program.helpInformation()rather than(cmd as any).options). - If a private field encodes a real contract:
- Ask: is the helper cohesive enough to extract as a pure public function? If yes, extract it with its own tiny unit-test file. This is the only "refactor-for-testability" move the taxonomy permits, and only because it clarifies architecture — see the no-extract-for-testability governor rule.
- Otherwise, cover the behavior through integration via the public surface.
- For third-party internal fields: ask the upstream to expose, or encapsulate behind a project-level adapter so the coupling lives in one place.
- Gate: preservation of regression-detection power. The transform often reveals
semantic-redundancy(private-method tests duplicate public-API tests) — fold in the same pass.
Example¶
Before¶
describe '#html_document?' do
it 'recognizes .html files' do
expect(described_class.send(:html_document?, 'foo.html')).to be true
end
end
After — option A, cover through the public surface¶
describe 'generate' do
it 'processes .html files but skips .md files' do
site = build_site(pages: ['foo.html', 'README.md'])
generator.generate(site)
expect(generator.processed_paths).to include('foo.html')
expect(generator.processed_paths).not_to include('README.md')
end
end
After — option B, extract as a public helper¶
Taken only when the helper has domain coherence worth publishing.
# lib/html_doc_filter.rb
module HtmlDocFilter
module_function
def html_document?(path) = path.end_with?('.html', '.htm')
end
# spec/html_doc_filter_spec.rb
describe HtmlDocFilter do
it '.html_document? recognizes .html and .htm extensions' do
expect(HtmlDocFilter.html_document?('foo.html')).to be true
expect(HtmlDocFilter.html_document?('foo.htm')).to be true
expect(HtmlDocFilter.html_document?('foo.md')).to be false
end
end
Option A covers the behavior through generate's observable effects; no private-method reach-through. Option B is taken only when the helper has domain coherence worth publishing.
Related modes¶
over-specified-mock— the "testing internal details" variant is the mock-shaped version of this smell.wrong-level— private-method tests at the "unit" level often belong at the component level, verifying via the public surface.tautology-theatre—spyOn(sut, 'privateMethod')combines both smells.
Polyglot notes¶
"What counts as private" is per-language:
- Python:
_single_leading_underscoreby convention;__double_leading_underscoreis name-mangled. - Ruby:
private,protected, withsend(:name)as a bypass. - TS/JS:
private/#name/ casts toany. - Go: lowercase is package-private; cross-package access requires export.
- JVM:
private, package-private;@VisibleForTestingas controlled relaxation. - C#:
internal,InternalsVisibleToattribute.
Ship a per-language signal table; the judgment layer is language-agnostic.