skip to content

"If a class is hard to test, it is too coupled" is standard advice. Explain why that probe is far more reliable in Go or Rust than in Python, Ruby or JavaScript, and what a team in the dynamic languages should measure instead.

level: seniorimportance: should knowfreq 35%

answer

  1. Probe measures substitutability, not tests
  2. Python patch target = import location, not definition
  3. Go and Rust: seam must be in the signature
  4. C# non-virtual default -> IFoo culture; Java virtual -> mock anything
  5. Measure patch count, depth, ownership

basics

~20 s

Python, Ruby and JavaScript can replace any import, method or global at runtime, so a badly coupled unit still tests green and the pain signal is suppressed. Go and Rust have no runtime patching, so substitutability must be designed in and the probe stays honest.

solid answer

~60 s

The probe measures how hard substitution is, and languages differ in whether substitution needs design or just a monkeypatch. - **Python** `unittest.mock.patch('billing.service.stripe')` rebinds a name in the module where it was imported. The class never had a seam, yet the test passes - and the test now depends on the import location, so a pure move breaks tests with no behaviour change. - **Ruby** reopens any class, and **JavaScript** intercepts the module registry with `jest.mock`, giving the same silence. - **Go** has no such hook: you pass a small consumer-declared interface or a function value, or you cannot fake it, so coupling shows up while you write the code. - **Rust** forces the same decision at compile time via a trait bound or `dyn Trait`. - **C#** is non-virtual by default, so mocking libraries that subclass need an extracted interface; **Java** is virtual by default and Mockito subclasses almost anything, which is why the two ecosystems evolved different levels of interface hygiene. In dynamic languages measure patch count and patch depth instead: patching something you do not own is the real coupling signal.

code

python · 10 lines
python
# billing/service.py
from vendor import stripe_client          # hard dependency, no seam

def charge(order):
    return stripe_client.charge(order.total)

# test
with patch('billing.service.stripe_client') as c:   # not 'vendor.stripe_client'
    charge(order)
# move the import to billing/__init__.py -> test fails, behaviour identical

go deeper

for a junior

Know that if you must construct half the system to test one class, the class depends on too much, and that passing collaborators in fixes it.

for a middle

Explain why mock-heavy tests can hide coupling, and name the Python patch-target problem specifically.

for a senior

Use graded signals - patch count, depth, ownership, setup size - and connect language defaults such as C#'s non-virtual members to the design cultures they produce.

for a principal

Decide the team's policy: where seams are mandatory, what may be patched, whether to enforce it with lint rules, and how much interface proliferation you will accept in exchange for an honest feedback signal.

## What the probe is really measuring "Hard to test" is not a property of tests. It is a proxy for one question: can I run this unit with a different collaborator than the one it normally has? If yes, the dependency goes through a seam you can control; if no, the unit is welded to a concrete collaborator. So the probe measures **substitutability**, and it is only trustworthy in a language where substitution has to be designed in advance. ## Where the probe stays honest **Go** offers no runtime patching of a function reference held by another package, no class reopening, and no reflective override. If a function calls `stripe.Charge(...)` directly, there is no test-time trick that replaces it. Your options are to accept a small interface declared by the consumer, or to accept a function value, or to restructure. Every one of those is a design change, made at authoring time, by the person creating the coupling. That is exactly what you want from a probe. **Rust** is similar with a different flavour: you take `impl Repo` or `&dyn Repo`, the choice being monomorphised generics versus dynamic dispatch. Either way the substitution point appears in the signature, so the coupling is visible in the API, not hidden in the body. ## Where the probe goes quiet **Python** gives you `unittest.mock.patch`, which rebinds an attribute on a module object for the duration of a test. The unit under test can reach out to a global HTTP client, a clock and a database, and every one of them can be replaced without the unit exposing a single seam. The test passes, the coverage number is green, and the design never had to improve. Worse, the patch target is a string naming *where the symbol was imported*, which means the test suite is now coupled to import locations: move the import from one module to another - a change with zero behavioural effect - and tests fail. You have traded design coupling for test-to-structure coupling, which is the harder kind to see. **Ruby** reopens classes and redefines methods anywhere, so the same suppression applies, with the extra hazard that a stub leaks across examples unless the framework scopes it. **JavaScript** with `jest.mock` intercepts the module registry, so an import that looked like a hard dependency is swapped wholesale. None of this means dynamic languages produce worse designs. It means the feedback signal that other languages get for free must be produced deliberately. ## A middle case worth knowing: virtuality by default **Java** dispatches instance methods virtually by default, so Mockito can subclass and override almost any non-final class. A team can therefore mock concrete classes for years without extracting a single interface. **C#** is the opposite: members are non-virtual unless marked, and `sealed` and `static` members close the door completely. Classic mocking libraries there work by generating a subclass, so they need either a `virtual` member or - the usual outcome - an extracted interface per collaborator. That single dispatch default is the historical reason .NET codebases are full of `IFoo`/`Foo` pairs while JVM codebases are not. It is a language default producing a design culture, not one community being more disciplined than the other. ## What to measure when the probe is unreliable Swap the binary question for graded ones: - **Patch count per test.** One patch is a seam; five is a god object. - **Patch depth.** Patching a collaborator you own is design; patching `requests.get`, `datetime.now` or a third-party client deep inside your unit means the dependency was never abstracted. - **Who owns the patched symbol.** Patching something you do not own is the strongest coupling signal available in a dynamic language. - **Setup size.** The number of objects you must construct before you can call the thing is language-independent and honest. - **Test fragility under pure moves.** If renaming a module or moving an import breaks tests without changing behaviour, the suite is bound to structure. ## The design conclusion Substitution should appear in the signature, not in the test harness. In Go and Rust the compiler enforces that; in Python, Ruby and JavaScript you get the same benefit only by choosing to pass collaborators in - constructor arguments, function parameters, a small protocol - and treating a heavy `patch` block as the smell it is. Testability remains a good probe everywhere; in patchable languages you have to read the patches, not the pass rate.

  • A Python test patches 'billing.service.stripe_client'. What coupling has that created, beyond the one in production code?
    The patch string names the module where the symbol was imported, so the test now depends on the codebase's import layout. Moving the import into another module, or importing the module rather than the name, breaks the test even though behaviour is unchanged. The suite is coupled to structure, which makes refactoring expensive.
  • Does this argument imply dependency injection frameworks are the answer?
    No. The point is that collaborators should arrive through the signature - a constructor parameter, a function argument, a small protocol - which is plain parameter passing. A container can wire that up at scale, but a framework is not what makes the design substitutable, and adding one to a codebase that still reaches for globals inside method bodies changes nothing.

saying these in an interview costs you the question

  • Treating a green suite full of patches as evidence of low coupling
  • Believing mocks are equally available everywhere - Go has no runtime patching and C# needs virtual members or an interface
  • Extracting an interface with exactly one implementation purely to satisfy a mocking library and calling it decoupling
  • Patching third-party symbols such as the HTTP client or the clock deep inside a unit rather than passing them in
  • Saying 'we use dependency injection' when collaborators are still constructed inside the method

context