Skip to content

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 / internal accessors (Kotlin, Java, C#).
  • Accessing lowercase fields from an adjacent test file in a different Go package via unsafe or 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 _helper is a naming convention — same-module test access is conventional and explicitly permitted in many codebases. Rust's #[cfg(test)] mod tests is 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 strict private.
  • Sanctioned visibility relaxation for testing. Some languages provide a deliberate escape hatch for test-time access: Java/Guava @VisibleForTesting, .NET's [InternalsVisibleTo], Kotlin internal, Go's same-package _test.go files, Ruby send accompanied 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 @VisibleForTesting would have sufficed; (x as any) cast in TS where the property could have been declared protected for the test subclass); do not flag the use of the sanctioned mechanism itself.

Prescribed Fix

  1. Drive the library's public API instead of reaching for internals (program.helpInformation() rather than (cmd as any).options).
  2. If a private field encodes a real contract:
  3. 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.
  4. Otherwise, cover the behavior through integration via the public surface.
  5. For third-party internal fields: ask the upstream to expose, or encapsulate behind a project-level adapter so the coupling lives in one place.
  6. 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.

  • 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-theatrespyOn(sut, 'privateMethod') combines both smells.

Polyglot notes

"What counts as private" is per-language:

  • Python: _single_leading_underscore by convention; __double_leading_underscore is name-mangled.
  • Ruby: private, protected, with send(:name) as a bypass.
  • TS/JS: private / #name / casts to any.
  • Go: lowercase is package-private; cross-package access requires export.
  • JVM: private, package-private; @VisibleForTesting as controlled relaxation.
  • C#: internal, InternalsVisibleTo attribute.

Ship a per-language signal table; the judgment layer is language-agnostic.