docs(arch): expand §04 with mock vs contract vs factory distinction

User asked: 'arent mocks also related to testing?' — yes, but the mock
plays two roles where the contract and factory play one each. §04 now
opens with that framing.

What's new:
- Section title bumped to 'Mocks, contracts & factories'
- New three-roles diagram at the top of §04 visualizing the chain:
  IArticlesRepository → MockArticlesRepository + ArticlesRepository
  → articlesRepositoryContract → articleFactory
- Two ordered lists explaining the mock's dual job (DI default binding
  + direct test fake) and the contract/factory's single test-only roles
- Three new expandable code examples:
  · the mock as DI binding (real BlogModule code)
  · the mock as direct test fake (use-case test, no container)
  · the contract running against both impls (proof-of-parity)
- New CSS for .three-roles-diagram, .role-* boxes, .role-arrow with
  vertical connector lines, .role-jobs lists with counter-leading
  decimal numbering

The existing Contracts and Factories cards stay in place below — they
provide the deeper detail on each individual artifact.
This commit is contained in:
2026-05-06 18:42:06 +02:00
parent 1b7e5eac50
commit ea17e6f998

View File

@@ -895,6 +895,234 @@ details.cf-detail > .detail-body {
padding-top: 16px;
}
/* ─── Three-roles diagram (anatomy of mock/contract/factory) ─────────── */
.three-roles-card { margin-bottom: 40px; }
.three-roles-card h3 {
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 60, "SOFT" 0, "wght" 380;
font-size: 42px;
line-height: 1.0;
letter-spacing: -0.018em;
margin: 0 0 18px;
}
.three-roles-card h3 em {
font-style: italic;
font-variation-settings: "opsz" 60, "SOFT" 100, "wght" 380;
color: var(--accent);
}
.three-roles-card p {
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 16, "SOFT" 40;
font-size: 15.5px;
line-height: 1.6;
color: var(--ink-2);
margin: 0 0 12px;
}
.three-roles-card p strong { color: var(--ink); font-weight: 500; }
.three-roles-card code {
font-family: "JetBrains Mono", monospace;
font-size: 12.5px;
background: var(--paper-2);
padding: 1px 5px;
border-radius: 2px;
}
.three-roles-diagram {
display: flex;
flex-direction: column;
gap: 0;
margin: 32px auto;
max-width: 760px;
}
.role {
border: 1px solid var(--ink);
background: var(--paper);
padding: 16px 20px;
border-radius: 3px;
position: relative;
}
.role .role-where {
font-family: "JetBrains Mono", monospace;
font-size: 9.5px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ink-3);
margin-bottom: 6px;
}
.role > strong {
display: block;
font-family: "JetBrains Mono", monospace;
font-weight: 500;
color: var(--ink);
font-size: 14.5px;
margin-bottom: 6px;
letter-spacing: 0.01em;
}
.role .role-what {
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 14, "SOFT" 40;
font-style: italic;
font-size: 13.5px;
line-height: 1.4;
color: var(--ink-2);
margin: 0;
}
.role .role-what em {
font-style: italic;
font-variation-settings: "opsz" 14, "SOFT" 100, "wght" 460;
color: var(--accent);
font-weight: 460;
}
.role .role-what code {
font-family: "JetBrains Mono", monospace;
font-style: normal;
font-size: 12px;
background: var(--paper-2);
padding: 1px 4px;
border-radius: 2px;
}
.role-interface {
border-width: 2px;
background: var(--paper-3);
}
.role-real {
background: #fbf1de;
border-color: var(--accent);
}
.role-real .role-where { color: var(--accent); }
.role-contract {
border-style: dashed;
border-color: var(--accent-2);
}
.role-contract .role-where { color: var(--accent-2); }
.role-factory {
border-color: var(--ok);
align-self: center;
width: min(78%, 540px);
}
.role-factory .role-where { color: var(--ok); }
.roles-pair {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.role-arrow {
align-self: center;
font-family: "JetBrains Mono", monospace;
font-size: 10px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ink-3);
padding: 14px 0;
position: relative;
}
.role-arrow::before {
content: "";
position: absolute;
left: 50%;
top: 0;
width: 1px;
height: 14px;
background: var(--rule-strong);
transform: translateX(-0.5px);
}
.role-arrow::after {
content: "";
position: absolute;
left: 50%;
bottom: 0;
width: 1px;
height: 14px;
background: var(--rule-strong);
transform: translateX(-0.5px);
}
.role-jobs {
list-style: none;
padding: 0;
margin: 14px 0 0;
counter-reset: job;
}
ol.role-jobs { counter-reset: job; }
ol.role-jobs > li {
counter-increment: job;
position: relative;
padding: 14px 18px 14px 56px;
margin-bottom: 8px;
background: var(--paper-3);
border-left: 2px solid var(--accent);
border-radius: 0 3px 3px 0;
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 16, "SOFT" 40;
font-size: 15.5px;
line-height: 1.55;
color: var(--ink);
}
ol.role-jobs > li::before {
content: counter(job, decimal-leading-zero);
position: absolute;
left: 18px;
top: 14px;
font-family: "JetBrains Mono", monospace;
font-size: 11px;
letter-spacing: 0.18em;
color: var(--accent);
}
ol.role-jobs > li strong {
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 16, "SOFT" 30, "wght" 520;
font-weight: 500;
color: var(--ink);
}
ol.role-jobs > li code {
font-family: "JetBrains Mono", monospace;
font-size: 12.5px;
background: var(--paper-2);
padding: 1px 5px;
border-radius: 2px;
}
ul.role-jobs {
list-style: none;
padding: 0;
margin: 14px 0 0;
}
ul.role-jobs > li {
padding: 12px 18px;
margin-bottom: 8px;
background: var(--paper-3);
border-left: 2px solid var(--accent-2);
border-radius: 0 3px 3px 0;
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 16, "SOFT" 40;
font-size: 15.5px;
line-height: 1.55;
color: var(--ink);
}
ul.role-jobs > li strong {
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 16, "SOFT" 30, "wght" 520;
font-weight: 500;
color: var(--ink);
}
ul.role-jobs > li code {
font-family: "JetBrains Mono", monospace;
font-size: 12.5px;
background: var(--paper-2);
padding: 1px 5px;
border-radius: 2px;
}
@media (max-width: 700px) {
.roles-pair { grid-template-columns: 1fr; }
.role-factory { width: 100%; }
}
/* ─── Verdict ────────────────────────────────────────────────────────── */
.verdict-block {
@@ -1073,7 +1301,7 @@ footer .colophon {
<li><a href="#anatomy"><span class="num">01</span>Feature anatomy</a></li>
<li><a href="#flow"><span class="num">02</span>Request flow</a></li>
<li><a href="#di"><span class="num">03</span>Dependency injection</a></li>
<li><a href="#contracts"><span class="num">04</span>Contracts &amp; factories</a></li>
<li><a href="#contracts"><span class="num">04</span>Mocks, contracts &amp; factories</a></li>
<li><a href="#verdict"><span class="num">05</span>The verdict</a></li>
</ol>
</div>
@@ -1300,11 +1528,135 @@ footer .colophon {
<div class="section-head">
<div class="section-num">§ 04</div>
<div>
<h2 class="section-title">Contracts &amp; <em>factories</em>.</h2>
<p class="section-blurb">Two small testing utilities you'll see in every feature: <code>__contracts__/&lt;x&gt;-repository.contract.ts</code> and <code>__factories__/&lt;x&gt;.factory.ts</code>. They look optional — they aren't, once you have more than two implementations of the same interface.</p>
<h2 class="section-title">Mocks, contracts &amp; <em>factories</em>.</h2>
<p class="section-blurb">Three things in every feature live near tests but play different roles. The <strong>mock repository</strong> is a real implementation of the repository interface — it's also the default DI binding. The <strong>contract</strong> is a portable test suite that runs against any implementation. The <strong>factory</strong> is a builder for valid entity values. The mock isn't <em>only</em> a test thing; that's the part that surprises people.</p>
</div>
</div>
<div class="cf-card three-roles-card">
<div class="cf-tag">three roles · one test surface</div>
<h3>Where each lives, and <em>why</em>.</h3>
<p>The mock is the surprising one. People assume it lives in <code>__mocks__/</code> because tests use it — but the DI container needs it as the <em>default binding</em> at runtime, and reaching into <code>__mocks__/</code> from production code crosses a boundary. So the mock lives next to the real implementation, in <code>infrastructure/repositories/</code>. They're siblings.</p>
<div class="three-roles-diagram">
<div class="role role-interface">
<div class="role-where">application/repositories/</div>
<strong>IArticlesRepository</strong>
<div class="role-what">interface — defines the shape every implementation must satisfy</div>
</div>
<div class="role-arrow">&nbsp; implemented by both &nbsp;</div>
<div class="roles-pair">
<div class="role role-mock">
<div class="role-where">infrastructure/repositories/</div>
<strong>MockArticlesRepository</strong>
<div class="role-what">in-memory · <em>default DI binding</em> · used by dev mode &amp; unit tests</div>
</div>
<div class="role role-real">
<div class="role-where">infrastructure/repositories/</div>
<strong>ArticlesRepository</strong>
<div class="role-what">Payload-backed · <em>production binding</em> · constructed at app boot</div>
</div>
</div>
<div class="role-arrow">&nbsp; both tested by &nbsp;</div>
<div class="role role-contract">
<div class="role-where">__contracts__/</div>
<strong>articlesRepositoryContract</strong>
<div class="role-what">portable test suite — same <code>it()</code> blocks, run twice (once per impl)</div>
</div>
<div class="role-arrow">&nbsp; consumes seed data from &nbsp;</div>
<div class="role role-factory">
<div class="role-where">__factories__/</div>
<strong>articleFactory</strong>
<div class="role-what">data builder — produces valid <code>Article</code> entities with overridable defaults</div>
</div>
</div>
<p style="margin-top: 32px;"><strong>The mock has two jobs.</strong></p>
<ol class="role-jobs">
<li><strong>Default DI binding.</strong> <code>BlogModule</code> binds <code>IArticlesRepository</code> to <code>MockArticlesRepository</code> at module-load time. Anything resolving that symbol — use cases, controllers, the whole chain — gets the mock until <code>bindProductionBlog(config)</code> swaps it for the real Payload-backed one. See §03.</li>
<li><strong>Test fake (direct injection).</strong> Unit tests skip the container entirely. They construct the mock with <code>new MockArticlesRepository()</code> and pass it directly into the use-case factory function. No DI involved — just a closure with a fake repo.</li>
</ol>
<p style="margin-top: 24px;"><strong>The contract and factory are pure test ergonomics</strong> — they only show up in <code>*.test.ts</code> files. Both live under <code>__</code>-prefixed directories that signal "test territory; not part of the public surface; not imported by runtime code." Their roles:</p>
<ul class="role-jobs">
<li><strong>Contract</strong> = a single suite of <code>it()</code> blocks parameterized by <code>buildSubject</code>. Run it against the mock, run it against the real impl. If they diverge — your mock is lying to you and you'd never have caught it without the contract.</li>
<li><strong>Factory</strong> = a sequence-counter builder. <code>articleFactory.build({ slug: "x" })</code> hands you a valid <code>Article</code> with sensible defaults; you only override the fields the test actually cares about. Used by the contract <em>and</em> by every use-case / controller test.</li>
</ul>
<details class="cf-detail">
<summary>show: the mock as DI binding (in module.ts)</summary>
<div class="detail-body">
<p>This is from <code>packages/blog/src/di/module.ts</code> — the very first binding in the module is the mock. Everything downstream (use cases, controllers) resolves through this default. <code>bindProductionBlog(config)</code> later replaces only this one line at app boot — use case + controller bindings stay put.</p>
<pre class="code" data-lang="typescript // packages/blog/src/di/module.ts"><span class="k">export const</span> <span class="n">BlogModule</span> = <span class="k">new</span> <span class="t">ContainerModule</span>((<span class="n">bind</span>) =&gt; {
<span class="c">// 1) Mock is the DEFAULT binding for the repo symbol.</span>
<span class="c">// Dev server, unit tests, storybook all resolve to this.</span>
<span class="n">bind</span>&lt;<span class="t">IArticlesRepository</span>&gt;(<span class="n">BLOG_SYMBOLS</span>.<span class="n">IArticlesRepository</span>)
.<span class="n">to</span>(<span class="t">MockArticlesRepository</span>);
<span class="c">// 2) Use cases consume IArticlesRepository — they don't know or</span>
<span class="c">// care which impl they got. Same factory function in either mode.</span>
<span class="n">bind</span>&lt;<span class="t">IGetArticlesUseCase</span>&gt;(<span class="n">BLOG_SYMBOLS</span>.<span class="n">IGetArticlesUseCase</span>)
.<span class="n">toDynamicValue</span>((<span class="n">ctx</span>) =&gt;
<span class="n">getArticlesUseCase</span>(
<span class="n">ctx</span>.<span class="n">container</span>.<span class="n">get</span>&lt;<span class="t">IArticlesRepository</span>&gt;(<span class="n">BLOG_SYMBOLS</span>.<span class="n">IArticlesRepository</span>),
),
);
<span class="c">// ... + 5 more bindings, all the same shape.</span>
});</pre>
</div>
</details>
<details class="cf-detail">
<summary>show: the mock as direct test fake (no container)</summary>
<div class="detail-body">
<p>Use-case + controller tests skip DI entirely. They construct the mock and pass it as the first argument to the use-case factory, then call the resulting closure with the input. Three lines of setup, then assertions.</p>
<pre class="code" data-lang="typescript // get-articles.use-case.test.ts"><span class="n">it</span>(<span class="s">"filters by status"</span>, <span class="k">async</span> () =&gt; {
<span class="c">// Construct the mock directly — no DI container, no rebinding.</span>
<span class="k">const</span> <span class="n">repo</span> = <span class="k">new</span> <span class="t">MockArticlesRepository</span>();
<span class="c">// Use the factory to seed valid entities (only override what we care about).</span>
<span class="n">articleFactory</span>.<span class="n">reset</span>();
<span class="k">await</span> <span class="n">repo</span>.<span class="n">createArticle</span>(<span class="n">articleFactory</span>.<span class="n">build</span>({ <span class="n">status</span>: <span class="s">"draft"</span> }));
<span class="k">await</span> <span class="n">repo</span>.<span class="n">createArticle</span>(<span class="n">articleFactory</span>.<span class="n">build</span>({ <span class="n">status</span>: <span class="s">"published"</span> }));
<span class="c">// Inject the mock into the use-case factory; call the resulting closure.</span>
<span class="k">const</span> <span class="n">useCase</span> = <span class="n">getArticlesUseCase</span>(<span class="n">repo</span>);
<span class="k">const</span> <span class="n">result</span> = <span class="k">await</span> <span class="n">useCase</span>({ <span class="n">status</span>: <span class="s">"published"</span> });
<span class="n">expect</span>(<span class="n">result</span>).<span class="n">toHaveLength</span>(<span class="n">1</span>);
});</pre>
</div>
</details>
<details class="cf-detail">
<summary>show: the contract testing both impls (the proof-of-parity bit)</summary>
<div class="detail-body">
<p>Two tiny test files, one shared suite. If the suite ever fails on the real impl but passes on the mock — your mock is lying about Payload's behavior and you'd ship a bug. The factory is doing real work here too: every <code>it()</code> in the suite uses <code>articleFactory.build(...)</code> for seed data, so the assertions stay readable.</p>
<pre class="code" data-lang="typescript // articles.repository.mock.test.ts"><span class="n">describe</span>(<span class="s">"MockArticlesRepository"</span>, () =&gt; {
<span class="n">articlesRepositoryContract</span>.<span class="n">run</span>(<span class="k">async</span> () =&gt; <span class="k">new</span> <span class="t">MockArticlesRepository</span>());
});
<span class="c">// articles.repository.test.ts (Payload-backed) — same suite, real impl</span>
<span class="n">vi</span>.<span class="n">mock</span>(<span class="s">"payload"</span>, () =&gt; ({ <span class="n">getPayload</span>: <span class="n">vi</span>.<span class="n">fn</span>() }));
<span class="n">describe</span>(<span class="s">"ArticlesRepository (Payload)"</span>, () =&gt; {
<span class="n">articlesRepositoryContract</span>.<span class="n">run</span>(<span class="k">async</span> () =&gt; {
<span class="k">const</span> <span class="n">stub</span> = <span class="n">buildPayloadStub</span>();
(<span class="n">getPayload</span> <span class="k">as</span> <span class="t">Mock</span>).<span class="n">mockResolvedValue</span>(<span class="n">stub</span>);
<span class="k">return new</span> <span class="t">ArticlesRepository</span>(<span class="n">stubPayloadConfig</span>);
});
});</pre>
<p style="margin-top: 16px;">When you run <code>pnpm test --filter @repo/blog</code>, the contract's twelve <code>it()</code> blocks run twice — once per implementation. Twenty-four assertions for the price of writing twelve.</p>
</div>
</details>
</div>
<div class="cf-grid">
<div class="cf-card">