Behaviour Testing
Test the public surface, never the private one.
A test that asserts on a private helper's argument list is not testing the code. It is pinning the code in place. The test passes when the wrong thing changes and fails when nothing real changes: the worst kind of green and the worst kind of red.
Opinion
I've watched too many teams confuse code coverage with confidence. They mock five collaborators, spy on a private helper, and assert that the helper got called with { id: 1 }. The bar turns green and the team feels safe. Then a junior refactors the helper from fetchUser({ id }) to fetchUser(id), the test goes red and the team blames the junior. The junior didn't break anything. The test was wrong from the moment it was written. The contract was “the user shows up on the page” and the test asserted “a function got called with a particular shape of argument.”
Adam's punch (brittle tests are the testing equivalent of premature abstraction) is the sharpest idea this principle has had in print. Sandi Metz's 3×2 matrix named which messages to test (incoming queries, incoming commands, outgoing commands) and which to leave alone (sent-to-self).1 Kent C. Dodds named the failure mode: implementation details are things users don't see, hear, or know about, and tests that assert on them are tests that lock in decisions the design should be free to walk away from.2 Both arguments converge on the same answer: the test addresses the unit by what callers can perceive, then asks the public surface a question.
The query-as-user half is the operational version. Testing Library's priority list puts getByRole first because the role tree is what a screen reader, a keyboard user and a sighted user all see in different forms.3 Playwright ports the same priority to E2E. Same axiom (test the public) on different surfaces. The unit test asserts on the rendered output. The component test asserts on the role tree. The E2E asserts on the page. None of them ask whether the helper function was called. F1 Single Responsibility is the design-side cousin: a unit that does one thing has one public surface, and one public surface is one test.
Copy a note and link
Grab this short comment and drop it into a PR comment or an LLM chat to prompt the right change.
Test the public surface, never the private one. Render and query like a user: getByRole, getByLabelText, getByText. Don't reach into private state, don't assert on internal helper calls, don't mock collaborators inside the same module. The test should break when behaviour changes, not when an internal name does. /tenet/behaviour-testing/TS6
AI eyes only
Rule: test behaviour, not implementation. Drive the same surface the user uses.
Reject: tests that pin internal collaborators with vi.mock. Reject: tests that break on a refactor that does not change behaviour. Reject: testing-library/no-node-access violations.4
Generate: tests that drive the public surface (function call, screen render, HTTP call) and assert observable outcomes. Use screen.getByRole first.
Diagnostic: refactor the implementation without touching the test. If the test breaks but the behaviour is unchanged, the test is wrong.
Why?
- Tests survive refactoring. Renaming a private helper, splitting a function, swapping the internal data structure: the test still passes because the public surface is unchanged.
- When a test fails, it's a real regression. The signal-to-noise ratio rises sharply once implementation-detail tests are gone. The suite stops crying wolf.
- Painful tests become design feedback. Public-surface tests that strain (e.g., the unit needs five mocks) are signalling an SRP failure per F1 Single Responsibility, not a testing failure.
- Accessibility follows automatically.
getByRoletraverses the role tree a screen reader uses. If the test passes, the page is at minimum role-traversable. - Reviews focus on behaviour. The reviewer doesn't need to ask “is this asserting the right private detail?”: the assertion is the public outcome.
- Test names describe products, not code. “Submitting an empty form shows an error” reads better than “handleSubmit calls validate with the form payload”.
- Coding agents stop writing brittle tests. With the rule in CLAUDE.md and Testing Library lint plugins on, the model writes
screen.getByRolefirst because that's the cheapest path through review.
Origins
The principle has the longest authority lineage in the entire Testing pillar. Sandi Metz's Magic Tricks of Testing talk1 at RailsConf 2013 introduced the canonical 3×2 matrix (incoming query, incoming command, outgoing query, outgoing command, sent-to-self) and named the rule explicitly: test only what crosses the public boundary. Ignore sent-to-self entirely. Steve Freeman and Nat Pryce's Growing Object-Oriented Software, Guided by Tests gave it the most-quoted single-line statement in the literature: “only mock types you own.”5
Kent Beck's Test Desiderata8 distilled it into the “structure-insensitive” property: tests should not change their result if the structure of the code changes. Michael Feathers's seam concept in Working Effectively with Legacy Code identified the right place for the test boundary before identifying that there should be one.9 Google's testing blog ran the consensus piece in 2013: Test Behaviour, Not Implementation10: that turned the rule into folklore inside FAANG-scale codebases.
The component-testing flavour is younger but converged independently. Kent C. Dodds's Testing Implementation Details2 (2020) made the canonical title-essay for the principle in the React tradition, and Testing Library's priority list3 made it executable. Marcy Sutton showed why it improves accessibility as a side effect: querying by role traverses the same tree a screen reader walks. The PUP standards/unit-testing.md file owns the on-canon formulation, citing Testing Library's guiding principle verbatim: “the more your tests resemble the way your software is used, the more confidence they can give you.”
Quotes
Implementation details are things which users of your code will not typically use, see, or even know about.
Test incoming query messages by making assertions about what they send back. Test incoming command messages by making assertions about direct public side effects.
Only mock types that you own.
The more your tests resemble the way your software is used, the more confidence they can give you.
Evidence
14 external sources, ranked by author authority. The first five are the canon; expand to see the rest, including the qualifiers and the named opposers. Each links out to its primary source.
- 01“Testing Implementation Details”SupportsThe canonical title-essay for the principle in the React era. Implementation details are anything users wouldn’t see, hear, or know about. Tests should not assert on them.
- 02The canonical 3×2 matrix: incoming query / incoming command / sent-to-self / outgoing query / outgoing command. Test only what crosses the public boundary. Ignore sent-to-self entirely.
- 03Designing Cost-Effective Tests. The book-length treatment of the matrix, with the boundary-rule and the sent-to-self exclusion expressed as design heuristics.
- 04The London-school reference. “Only mock types you own”: the most-quoted single line in the boundary-mocking literature.
- 05Test DesiderataSupportsThe Structure-insensitive property: tests should not change their result if the structure of the code changes. Beck’s mature position is the public-surface position.
- 06A Testing on the Toilet episode by Andrew Trenk: in most cases, tests should focus on the public API. Brittle tests = implementation tests.
- 07The seam concept names the right place for the test boundary before naming that there should be one. Public-surface testing requires identifying which seam is public.
- 08Ch. 5 “Mocks and test fragility” argues internal mocks make tests fragile and tells you to mock unmanaged dependencies only.
- 09The axiom: “the more your tests resemble the way your software is used, the more confidence they can give you.” The principle that the priority list operationalises.
- 10Names the locator anti-patterns that arise from implementation-detail thinking: destructured queries, container.querySelector, getByTestId as default.
- 11“Mocks Aren’t Stubs”QualifiesThe classicist-vs-mockist debate, named. The mature classicist position is public-surface. The mockist position concedes the rule once “mock types you own” is read seriously.
- 12Clean Code, Ch. 9 “Unit Tests”SupportsFIRST + Single Concept Per Test. Its “Single Concept per Test” and F.I.R.S.T. rules predate Dodds by more than a decade.
- 13The vendor-canonical position: “Validate private methods with public methods.” The .NET ecosystem’s documented version of the rule.
- 14The strongest published argument that the mature consensus is the boundary-only reading. London-school is alive but moves toward classical at the boundary.
Fourteen sources, three lineages. The OOP-design tradition (Metz, Freeman & Pryce, Beck) presents it as a design rule. The component-testing tradition (Dodds at the top of the row) states it as a query rule. The Google testing-blog tradition further down presents it as a maintenance rule. They agree on the answer.
Examples
Before pokes private cache and a private helper.
it("caches the lookup and calls the formatter", () => { rescueHedgehog({ name: "Spike" }); expect(rescueHedgehog.__internal.cache).toEqual({ Spike: "burrow-7" }); expect(formatHedgehogStatus).toHaveBeenCalledWith("Spike", "burrow-7"); });
After calls the public function, asserts the public result.
it("schedules a rescue and returns the assigned reserve slot", () => { const result = rescueHedgehog({ name: "Spike" }); expect(result).toEqual({ status: "scheduled", reserveSlot: "burrow-7" }); }); it("returns no slot when the reserve is at capacity", () => { fillReserveToCapacity(); const result = rescueHedgehog({ name: "Spike" }); expect(result.status).toBe("waitlisted"); });
Before pokes private cache and a private helper.
it("caches the lookup and calls the formatter", () => { rescueHedgehog({ name: "Spike" }); expect(rescueHedgehog.__internal.cache).toEqual({ Spike: "burrow-7" }); expect(formatHedgehogStatus).toHaveBeenCalledWith("Spike", "burrow-7"); });
After calls the public function, asserts the public result.
it("schedules a rescue and returns the assigned reserve slot", () => { const result = rescueHedgehog({ name: "Spike" }); expect(result).toEqual({ status: "scheduled", reserveSlot: "burrow-7" }); }); it("returns no slot when the reserve is at capacity", () => { fillReserveToCapacity(); const result = rescueHedgehog({ name: "Spike" }); expect(result.status).toBe("waitlisted"); });
Before pokes private cache and a private helper.
it("caches the lookup and calls the formatter", () => { rescueHedgehog({ name: "Spike" }); expect(rescueHedgehog.__internal.cache).toEqual({ Spike: "burrow-7" }); expect(formatHedgehogStatus).toHaveBeenCalledWith("Spike", "burrow-7"); });
After calls the public function, asserts the public result.
it("schedules a rescue and returns the assigned reserve slot", () => { const result = rescueHedgehog({ name: "Spike" }); expect(result).toEqual({ status: "scheduled", reserveSlot: "burrow-7" }); }); it("returns no slot when the reserve is at capacity", () => { fillReserveToCapacity(); const result = rescueHedgehog({ name: "Spike" }); expect(result.status).toBe("waitlisted"); });
Before pokes private cache and a private helper.
def test_caches_the_lookup_and_calls_the_formatter(mocker) -> None: spy = mocker.spy(rescue, "_format_hedgehog_status") rescue_hedgehog(name="Spike") assert rescue._cache == {"Spike": "burrow-7"} spy.assert_called_with("Spike", "burrow-7")
After calls the public function, asserts the public result.
def test_schedules_a_rescue_and_returns_the_assigned_reserve_slot() -> None: result = rescue_hedgehog(name="Spike") assert result == RescueOutcome(status="scheduled", reserve_slot="burrow-7") def test_returns_no_slot_when_the_reserve_is_at_capacity() -> None: fill_reserve_to_capacity() result = rescue_hedgehog(name="Spike") assert result.status == "waitlisted"
Before pokes private cache and a private helper.
@Test void cachesTheLookupAndCallsTheFormatter() { RescueService spy = Mockito.spy(rescuer); spy.rescue(new HedgehogIntake("Spike")); Map<String, String> cache = ReflectionTestUtils.getField(spy, "cache"); assertEquals(Map.of("Spike", "burrow-7"), cache); verify(spy).formatHedgehogStatus("Spike", "burrow-7"); }
After calls the public function, asserts the public result.
@Test void schedulesARescueAndReturnsTheAssignedReserveSlot() { RescueOutcome result = rescuer.rescue(new HedgehogIntake("Spike")); assertEquals(new RescueOutcome("scheduled", "burrow-7"), result); } @Test void returnsNoSlotWhenTheReserveIsAtCapacity() { fillReserveToCapacity(); RescueOutcome result = rescuer.rescue(new HedgehogIntake("Spike")); assertEquals("waitlisted", result.status()); }
Before pokes private cache and a private helper.
public function testCachesTheLookupAndCallsTheFormatter(): void { $rescuer = $this->createPartialMock(RescueService::class, ['formatHedgehogStatus']); $rescuer->rescue(new HedgehogIntake(name: 'Spike')); $cache = (new ReflectionClass($rescuer))->getProperty('cache')->getValue($rescuer); $this->assertSame(['Spike' => 'burrow-7'], $cache); }
After calls the public function, asserts the public result.
public function testSchedulesARescueAndReturnsTheAssignedReserveSlot(): void { $result = $this->rescuer->rescue(new HedgehogIntake(name: 'Spike')); $this->assertEquals(new RescueOutcome('scheduled', 'burrow-7'), $result); } public function testReturnsNoSlotWhenTheReserveIsAtCapacity(): void { $this->fillReserveToCapacity(); $result = $this->rescuer->rescue(new HedgehogIntake(name: 'Spike')); $this->assertSame('waitlisted', $result->status); }
Enforcement
Apply these rules in eslint.config.mjs. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| testing-library/no-node-access | eslint-plugin-testing-library | .firstChild, .parentNode, .children walks inside tests: DOM traversal posing as a query. |
| testing-library/no-container | eslint-plugin-testing-library | container.querySelector: the canonical content-coupling escape hatch the rule rules out. |
| testing-library/prefer-screen-queries | eslint-plugin-testing-library | destructured render() helpers: keeps every query traceable through the screen object. |
| testing-library/no-render-in-lifecycle | eslint-plugin-testing-library | render() called inside beforeEach/beforeAll. A signal that the test is asserting on setup state, not behaviour. |
| jest-dom/prefer-in-document | eslint-plugin-jest-dom | expect(...).toHaveLength(1) and similar: pushes assertions toward the public toBeInTheDocument matcher. |
| jest-dom/prefer-to-have-text-content | eslint-plugin-jest-dom | raw .textContent === checks. The matcher keeps the test focused on what users perceive. |
| vitest/expect-expect | @vitest/eslint-plugin | tests with no expect() call: the silent green that is worse than a red one. Catches empty placeholder tests left after a refactor. |
eslint.config.mjsconfiguration snippet
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
import testingLibrary from 'eslint-plugin-testing-library';
import jestDom from 'eslint-plugin-jest-dom';
import vitest from '@vitest/eslint-plugin';
export default defineConfig({
files: ['**/*.spec.{ts,tsx}', '**/*.test.{ts,tsx}'],
plugins: { 'testing-library': testingLibrary, 'jest-dom': jestDom, vitest },
rules: {
'testing-library/no-node-access': 'error',
'testing-library/no-container': 'error',
'testing-library/prefer-screen-queries': 'error',
'testing-library/await-async-events': 'error',
'testing-library/no-render-in-lifecycle': 'error',
'testing-library/no-debugging-utils': 'error',
'jest-dom/prefer-in-document': 'error',
'jest-dom/prefer-to-have-text-content': 'error',
'vitest/no-conditional-tests': 'error',
'vitest/no-conditional-in-test': 'error',
'vitest/expect-expect': 'error',
}
});Apply these rules in eslint.config.mjs. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| testing-library/no-node-access | eslint-plugin-testing-library | .firstChild, .parentNode, .children walks inside tests: DOM traversal posing as a query. |
| testing-library/no-container | eslint-plugin-testing-library | container.querySelector: the canonical content-coupling escape hatch the rule rules out. |
| testing-library/prefer-screen-queries | eslint-plugin-testing-library | destructured render() helpers: keeps every query traceable through the screen object. |
| testing-library/no-render-in-lifecycle | eslint-plugin-testing-library | render() called inside beforeEach/beforeAll. A signal that the test is asserting on setup state, not behaviour. |
| jest-dom/prefer-in-document | eslint-plugin-jest-dom | expect(...).toHaveLength(1) and similar: pushes assertions toward the public toBeInTheDocument matcher. |
| jest-dom/prefer-to-have-text-content | eslint-plugin-jest-dom | raw .textContent === checks. The matcher keeps the test focused on what users perceive. |
| vitest/expect-expect | @vitest/eslint-plugin | tests with no expect() call: the silent green that is worse than a red one. Catches empty placeholder tests left after a refactor. |
eslint.config.mjsconfiguration snippet
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
import testingLibrary from 'eslint-plugin-testing-library';
import jestDom from 'eslint-plugin-jest-dom';
import vitest from '@vitest/eslint-plugin';
export default defineConfig({
files: ['**/*.spec.{ts,tsx}', '**/*.test.{ts,tsx}'],
plugins: { 'testing-library': testingLibrary, 'jest-dom': jestDom, vitest },
rules: {
'testing-library/no-node-access': 'error',
'testing-library/no-container': 'error',
'testing-library/prefer-screen-queries': 'error',
'testing-library/await-async-events': 'error',
'testing-library/no-render-in-lifecycle': 'error',
'testing-library/no-debugging-utils': 'error',
'jest-dom/prefer-in-document': 'error',
'jest-dom/prefer-to-have-text-content': 'error',
'vitest/no-conditional-tests': 'error',
'vitest/no-conditional-in-test': 'error',
'vitest/expect-expect': 'error',
}
});Apply these rules in eslint.config.mjs. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| testing-library/no-node-access | eslint-plugin-testing-library | DOM tree walking inside tests. |
| testing-library/no-container | eslint-plugin-testing-library | container.querySelector: bypass the role-based query. |
| testing-library/prefer-screen-queries | eslint-plugin-testing-library | destructured query helpers; routes everything through screen. |
| jest-dom/prefer-in-document | eslint-plugin-jest-dom | length-based existence checks where toBeInTheDocument fits. |
eslint.config.mjsconfiguration snippet
import testingLibrary from 'eslint-plugin-testing-library';
import jestDom from 'eslint-plugin-jest-dom';
export default [
{
files: ['**/*.spec.{js,jsx}', '**/*.test.{js,jsx}'],
plugins: { 'testing-library': testingLibrary, 'jest-dom': jestDom },
rules: {
'testing-library/no-node-access': 'error',
'testing-library/no-container': 'error',
'testing-library/prefer-screen-queries': 'error',
'jest-dom/prefer-in-document': 'error',
},
},
];Apply these rules in pyproject.toml. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| PT011 (pytest-raises-too-broad) | Ruff | pytest.raises with a non-specific Exception type: the catch-all that lets implementation errors look like behaviour errors. |
| PT015 (pytest-assert-always-false) | Ruff | assert False used as a deliberate failure: pytest.fail() states the intent more clearly. |
| PT018 (pytest-composite-assertion) | Ruff | assert a and b and c: the multi-concept assertion that hides which behaviour failed. |
| semgrep: forbid private-attribute access | Semgrep | obj._private_attr inside test files: reaching past the public surface to verify state. |
| semgrep: forbid getattr on dunder-private | Semgrep | getattr(obj, '__name_mangled'): the reflection escape hatch. |
| S110 (try-except-pass) | Ruff | try/except: pass blocks inside tests, silent swallowing of behaviour errors. The opposite of a behaviour test. |
pyproject.tomlconfiguration snippet
[tool.ruff.lint]
select = [
"PT",
"S",
]
[tool.ruff.lint.flake8-pytest-style]
fixture-parentheses = falseApply these rules in config/pmd-tests.xml. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| custom XPath rule: AvoidPrivateMethodReflectionInTests | PMD | getDeclaredMethod / setAccessible(true) inside test sources: reaching past private to verify implementation. |
| UnitTestShouldIncludeAssert | PMD | @Test methods with no assertion: the empty-green smell. Catches placeholder tests left after a refactor. |
| S5961 (too many assertions in one test) | SonarQube Java | test methods with more assertions than the configured maximum (25 by default): usually the smell of multiple behaviours hidden inside one assertion stack. |
| S2925 (Thread.sleep in tests) | SonarQube Java | sleep() inside tests: the workaround for a brittle assertion that should have polled the public surface. |
| S5969 (mocking all non-private methods) | SonarQube Java | tests that mock every non-private method of a class: a sign the test grew too complex. Use the class itself, or a subclass written for the test. |
| S2187 (test class with no tests) | SonarQube Java | test classes whose @Test methods were renamed away during a refactor and never replaced. |
config/pmd-tests.xmlconfiguration snippet
<?xml version="1.0"?>
<ruleset name="ts6-behaviour-testing"
xmlns="http://pmd.sourceforge.net/ruleset/2.0.0">
<description>Test through public methods only.</description>
<rule ref="category/java/bestpractices.xml/UnitTestShouldIncludeAssert"/>
<rule ref="category/java/bestpractices.xml/UnusedPrivateMethod"/>
<rule name="AvoidPrivateMethodReflectionInTests"
language="java"
message="Don't reach into private methods via reflection. Test through public surface."
class="net.sourceforge.pmd.lang.rule.xpath.XPathRule">
<properties>
<property name="xpath">
<value><![CDATA[
//MethodCall[@MethodName='getDeclaredMethod' or @MethodName='setAccessible']
]]></value>
</property>
</properties>
</rule>
</ruleset>Apply these rules in phpmd.tests.xml. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| custom rule: AvoidReflectionInTests | PHPMD | ReflectionClass, ReflectionMethod, ReflectionProperty inside tests: bypassing the public surface to verify private state. |
| S5785 (dedicated assertion) | SonarQube PHP | assertTrue(count($x) === 0) and friends: pushes tests toward the dedicated matcher and away from intermediate-state poking. |
| S2187 (test class with no tests) | SonarQube PHP | test classes whose methods were renamed away during a refactor. |
| S2964 (sleep should not be called) | SonarQube PHP | sleep() calls inside tests: the workaround for a brittle assertion that should have polled the public surface. |
phpmd.tests.xmlconfiguration snippet
<?xml version="1.0"?>
<ruleset name="TS6"
xmlns="http://pmd.sourceforge.net/ruleset/2.0.0">
<rule ref="rulesets/cleancode.xml/StaticAccess"/>
<rule name="AvoidReflectionInTests"
message="Tests should drive the public surface, not reach in via reflection."
class="PHPMD\Rule\CleanCode\StaticAccess">
<properties>
<property name="prohibitedClasses" value="ReflectionClass,ReflectionMethod,ReflectionProperty"/>
</properties>
</rule>
</ruleset>AI rules
.cursor/rules/ts6-behaviour-testing.mdc--- alwaysApply: true --- ## Prickles TS6: Behaviour Testing Test through the public surface only. The signature, the rendered output, the response payload: anything a caller or user can see. Internal helpers and private collaborators stay invisible. Render and query like a user. Locate by role, label, or text: the same tree a screen reader walks. Implementation-detail selectors (CSS, data-testid as default, DOM traversal) are anti-patterns. If the test breaks because an internal name changed, the test was wrong. If it breaks because the user-visible behaviour changed, the test was right. Don't reach into private state to verify. Drive the public action. Assert the public outcome.
Repo layout, CI, and ESLint wiring for these paths live on /implementation, not repeated on every tenet.
Counter-argument
The honest pushback comes from the strict London-school reading of GOOS: every interaction between an object and its peers (its dependencies, notifications and adjustments) should be tested with mocks, because that's how tests drive design.5 If you only test the public surface, the argument runs, you defer the design feedback the test was supposed to give you. Tests-as-design-pressure (Beck's Tidy First?, Feathers's seam concept) extends the point: the moment a private helper becomes painful to test through the public surface is the moment the helper wanted to be its own unit. Refusing to mock it loses the signal.6
Counter-argument retort
The London-school reading is half right. The mature classicist response: Fowler in Mocks Aren't Stubs, Seemann's Mocks for Commands, Stubs for Queries, Khorikov's Unit Testing, conceded the point that mocks are design pressure but moved the line: mock at the boundary, not in the middle.7 A mock at the port is a question about the system's shape. A mock on a private helper is an answer the test agreed to before the design did. The classicist consensus, the Google testing-blog material, and Khorikov's book all converge: only mock unmanaged dependencies that cross a process boundary.
The tests-as-design-pressure argument applies on the design side rather than the test side. When a private helper becomes painful to test through the public surface, that is real signal: the helper wants to be a unit. Beck's answer in Tidy First? is to tidy first: extract the unit, give it its own public surface, then test it through that. The design moves; the test follows. The wrong move is to leave the helper private and reach in with a spy. That captures the pain without doing anything about it.6
The query-as-user half is why the rule sticks in component code. Without it, “test the public” is hand-wavy and you get tests that assert on component.state.formData.email. With it, the locator priority list is the enforcement: getByRole first, then getByLabelText, getByText, and getByTestId as the last resort.3 Pair the rule with TS5 Structural Assertions for the E2E surface and TS7 Test Isolation for the unit boundary. Three rules, three surfaces, one principle: the test exercises what callers can see and nothing else.
Notes
- [1]Sandi Metz: “The Magic Tricks of Testing”, RailsConf 2013. The canonical 3×2 matrix: incoming query / incoming command / sent-to-self / outgoing query / outgoing command, with rules such as “Test incoming query messages by making assertions about what they send back” and “Do not test private methods.”
- [2]Kent C. Dodds: “Testing Implementation Details” (kentcdodds.com, 2020). “Implementation details are things which users of your code will not typically use, see, or even know about.”
- [3]Testing Library maintainers: “About Queries — locator priority”. The order: getByRole, getByLabelText, getByPlaceholderText, getByText, getByDisplayValue, getByAltText, getByTitle, getByTestId.
- [4]eslint-plugin-testing-library maintainers: “no-node-access” rule. Forbids DOM tree walking inside tests. Flags the .firstChild / .parentNode / .children patterns that masquerade as queries.
- [5]Steve Freeman & Nat Pryce: Growing Object-Oriented Software, Guided by Tests (Addison-Wesley, 2009). The most-quoted single line in the boundary-mocking literature: “only mock types you own.”
- [6]Kent Beck: Tidy First? (O’Reilly, 2023). Frames painful tests as design pressure: when a private helper is hard to test through the public surface, tidy first by extracting it.
- [7]Mark Seemann: “Mocks for Commands, Stubs for Queries” (blog.ploeh.dk, 2013). Use Mocks for Commands, Stubs for Queries: a rule Seemann built on Command Query Separation, and the same advice GOOS gives in ch. 24 as “Allow Queries; Expect Commands”.
- [8]Kent Beck: Test Desiderata (kentbeck.github.io, 2019). The Structure-insensitive property: tests should not change their result if the structure of the code changes.
- [9]Michael Feathers: Working Effectively with Legacy Code (Prentice Hall, 2004). The seam concept: a place where you can alter behaviour in your program without editing in that place.
- [10]Google Testing Blog: “Testing on the Toilet: Test Behaviour, Not Implementation” (testing.googleblog.com, 2013). A Testing on the Toilet episode by Andrew Trenk: in most cases tests should focus on the public API, and tests independent of implementation details don't need changing each time the implementation changes.