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:
@@ -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 & factories</a></li>
|
||||
<li><a href="#contracts"><span class="num">04</span>Mocks, contracts & 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 & <em>factories</em>.</h2>
|
||||
<p class="section-blurb">Two small testing utilities you'll see in every feature: <code>__contracts__/<x>-repository.contract.ts</code> and <code>__factories__/<x>.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 & <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">↓ implemented by both ↓</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 & 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">↓ both tested by ↓</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">↓ consumes seed data from ↓</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>) => {
|
||||
<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><<span class="t">IArticlesRepository</span>>(<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><<span class="t">IGetArticlesUseCase</span>>(<span class="n">BLOG_SYMBOLS</span>.<span class="n">IGetArticlesUseCase</span>)
|
||||
.<span class="n">toDynamicValue</span>((<span class="n">ctx</span>) =>
|
||||
<span class="n">getArticlesUseCase</span>(
|
||||
<span class="n">ctx</span>.<span class="n">container</span>.<span class="n">get</span><<span class="t">IArticlesRepository</span>>(<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> () => {
|
||||
<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>, () => {
|
||||
<span class="n">articlesRepositoryContract</span>.<span class="n">run</span>(<span class="k">async</span> () => <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>, () => ({ <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>, () => {
|
||||
<span class="n">articlesRepositoryContract</span>.<span class="n">run</span>(<span class="k">async</span> () => {
|
||||
<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">
|
||||
|
||||
Reference in New Issue
Block a user