[{"data":1,"prerenderedAt":759},["ShallowReactive",2],{"post-\u002Fblog\u002Fthe-test-that-could-not-fail":3,"blog-all":256},{"id":4,"title":5,"author":6,"body":7,"category":237,"date":238,"description":239,"draft":240,"extension":241,"image":242,"meta":243,"navigation":244,"path":245,"project":246,"readingMinutes":247,"seo":248,"series":249,"seriesOrder":249,"stem":250,"tags":251,"__hash__":255},"blog\u002Fblog\u002Fthe-test-that-could-not-fail.md","The Test That Could Not Fail","EkoHacks Team",{"type":8,"value":9,"toc":225},"minimark",[10,14,17,20,25,37,52,55,59,73,86,96,100,103,111,114,118,121,124,127,131,137,147,150,153,157,180,183,186,190,193,206,209,213,216,219,222],[11,12,13],"p",{},"Every test makes a promise in its name. This is the story of one that made the promise, passed every run, and was quietly watching the wrong thing the whole time. We caught it not by reading the test harder, but by asking it a single blunt question and letting it answer in public.",[11,15,16],{},"Here is the shape of it in EkoLite, our small real time backend. On the server we keep one wrapper around the WebSocket layer. The app hands it a message and it sends it to a client, or broadcasts it to all of them, and when a browser drops, the wrapper notices and tells the app the client has gone. Connect, send, broadcast, disconnect. Four small promises.",[11,18,19],{},"We had a coverage problem on that file. It read 39 percent, the lowest of any wrapper in the tree, and the reason was not laziness. It was duplication.",[21,22,24],"h2",{"id":23},"three-copies-of-one-idea","Three copies of one idea",[11,26,27,28,32,33,36],{},"The wrapper came in three classes, all implementing the same interface. One stood up a standalone WebSocket server. One registered a route on Fastify, which is the path that actually ships. One was the in memory stub the tests built. Each carried its own map of clients, its own id counter, its own ",[29,30,31],"code",{},"send",", its own ",[29,34,35],{},"broadcast",", its own disconnect handling, its own tracking. Three copies of one idea.",[11,38,39,40,43,44,47,48,51],{},"The unit suite only ever built the stub. So the two real classes, the ones that run in production, only ran under integration tests, and our coverage pass does not count those. Roughly two thirds of the file never ran while coverage was measured. The damage showed in the shape of the code too: ",[29,41,42],{},"trackConnections"," and ",[29,45,46],{},"trackMessages"," literally threw ",[29,49,50],{},"only available on null instances"," on both real classes. Tracking that works only on the stub is the tell. It means the stub is a separate object pretending to be the real one, rather than the real one with the lights switched off.",[11,53,54],{},"That distinction is the whole job, so it is worth being precise about it.",[21,56,58],{"id":57},"what-james-shore-actually-told-us-to-do","What James Shore actually told us to do",[11,60,61,62,65,66,43,69,72],{},"The pattern we were reaching for is James Shore's Nullable Infrastructure, and his own worked example is, by a happy accident, almost exactly our problem. He builds a server side socket wrapper with ",[29,63,64],{},"simulateConnection",", ",[29,67,68],{},"simulateMessage",[29,70,71],{},"simulateDisconnection",", the same simulation seam we needed.",[11,74,75,76,65,79,65,82,85],{},"The important part of his example is not the simulation methods. It is one line of discipline underneath them. His real socket handler and his simulation methods both funnel into the same private handlers, ",[29,77,78],{},"handleConnection",[29,80,81],{},"handleMessage",[29,83,84],{},"handleDisconnection",". One body of logic, reached through two doors. The stub is not a second class holding a copy of the behaviour. It is the same behaviour, reached through a different entrance. His own rule, in his own words: share as much code as possible with the code that handles real external events.",[11,87,88,89,43,92,95],{},"So the shape we wanted was clear. A boundary that abstracts only the genuinely different bit, which is how a connection arrives, and a single wrapper that owns everything after it. We called the boundary ",[29,90,91],{},"ServerSocketLike",[29,93,94],{},"ConnectionSource",". Real WebSocket, real Fastify and the in memory null each accept a connection their own way and then hand the same wrapper a connected socket. The wrapper does the clients map, the sending, the broadcasting, the disconnect, the tracking, once, and never asks which kind of socket it is holding.",[21,97,99],{"id":98},"what-we-built-on-the-first-pass","What we built on the first pass",[11,101,102],{},"The first pass folded the three classes into one. That part was right, and it is real progress. The throws were gone, the tracking was uniform, the boundary was sitting where it should.",[11,104,105,106,43,108,110],{},"But it kept two doors into the behaviour and wired them to two different rooms. ",[29,107,31],{},[29,109,35],{}," branched on whether the client was a stub: push into the stub's array on one side, call the real socket on the other. Disconnect existed twice over. Once in the closure the real socket fires on close, which deletes the client and emits the event. Once in a separate private method the stub calls, which deletes the client and emits the event. The same three lines, written twice, living a few methods apart.",[11,112,113],{},"So the structure was one class. The behaviour was still two copies. We had moved the seam, not removed it. And a seam you cannot see is the most expensive kind.",[21,115,117],{"id":116},"a-question-worth-sitting-with","A question worth sitting with",[11,119,120],{},"We did not open the review by saying this is wrong, change it. We asked the test a question it had to answer out loud.",[11,122,123],{},"The headline test for this work was the one written to prove the core promise: removes a client and notifies onDisconnect exactly once. It was green. So we asked it the only question that ever matters about a green test. What would have to break for you to go red?",[11,125,126],{},"The honest way to ask is to break the thing the test is named after and see if it notices. So we emptied the body of the disconnect that ships, the closure the real socket fires on close, and ran everything.",[21,128,130],{"id":129},"the-receipt","The receipt",[11,132,133,136],{},[29,134,135],{},"npm run test",", the unit suite: 168 green, including the exactly once test. Not a flicker.",[11,138,139,142,143,146],{},[29,140,141],{},"npm run test:integration",", the real suite: two red, both landing on ",[29,144,145],{},"expect(ws.clientCount).toBe(0)",", both reporting expected 1 to be 0.",[11,148,149],{},"Read that back slowly. We deleted the only code that removes a client when a real socket closes, and the test named removes a client did not move. It had been watching the stub's private copy of disconnect the whole time, through the door only the stub uses. The behaviour it claimed to guard could be lifted clean out of production and not one unit test would notice. The integration suite caught it, but the integration suite is precisely the slow broad test the pattern is meant to free us from, and our coverage pass does not even run it. That is also why the first pass only lifted the file from 39 percent to 55, well short of where it should land. The production paths were still hiding in the suite that coverage ignores.",[11,151,152],{},"A test that stays green when you delete the behaviour it is named after is not testing that behaviour. It is testing something that resembles it. It is the same shape as any fallback that only your own tests keep alive, and we keep meeting it.",[21,154,156],{"id":155},"the-fix-is-one-door","The fix is one door",[11,158,159,160,162,163,43,165,167,168,171,172,175,176,179],{},"The repair is the drawing Shore handed us. Push the null down into the boundary. The null ",[29,161,91],{}," captures what the wrapper sends, so ",[29,164,31],{},[29,166,35],{}," call ",[29,169,170],{},"socket.send"," with no branch at all. The null socket's ",[29,173,174],{},"close"," fires the same ",[29,177,178],{},"onClose"," the wrapper registered for every real socket, so the disconnect lives once, in one closure, and the stub reaches it through the same door a real connection does.",[11,181,182],{},"We have already done this once, a layer down, on the client. There the null socket is a genuine member of the boundary, the simulation seam hangs off the null and not off the shared interface, and the wrapper never once asks whether it is talking to a real socket or a null. The pattern was already in the house. This work is mirroring it onto the server.",[11,184,185],{},"When there is one door, the exactly once test runs the production disconnect. And the coverage climbs without anyone chasing it, because the only thing left for integration to cover is the genuinely different work of accepting a connection in each adapter, which is exactly where integration belongs.",[21,187,189],{"id":188},"the-move-you-can-reproduce","The move you can reproduce",[11,191,192],{},"If you would rather feel this than read it, the move is short, and on today's code it lands the other way up, which is the proof the fix took.",[11,194,195,196,199,200,202,203,205],{},"In ",[29,197,198],{},"server\u002Finfrastructure\u002Fwebsocket.ts",", empty the body of the ",[29,201,178],{}," closure the wrapper registers, so a real disconnect does nothing. Run ",[29,204,135],{}," and predict the result before you look. It goes red now, on the exactly once test, because the wrapper has one door and the unit suite finally stands where the disconnect lives. Back when the behaviour had two copies, this same mutation left the unit suite green and only the slow integration suite noticed. The colour of that result is the whole lesson. Then put the closure back.",[11,207,208],{},"The mutation is not a fix. It is a question you ask the suite, and the suite cannot lie when you ask it that way.",[21,210,212],{"id":211},"why-we-work-this-way","Why we work this way",[11,214,215],{},"We treat a passing test as a claim, not a proof, and we read the claim by asking what would falsify it. Shore's pattern language gives everyone the same words for that reading, so the conversation is about overlapping sociable tests and embedded stubs and where the seam belongs, not about taste or seniority.",[11,217,218],{},"We pose the problem instead of handing down the verdict. The mutation was not us marking work. It was a question dropped where it could be answered from the code, in five minutes, by whoever wrote the line, who then owns the conclusion because they watched it happen rather than being told.",[11,220,221],{},"And we stay suspicious of any code, and any test, that only our own suite seems to need. That suspicion is most of the craft.",[11,223,224],{},"A test should fail when the thing it names breaks. Anything else is a green light wired to nothing.",{"title":226,"searchDepth":227,"depth":227,"links":228},"",2,[229,230,231,232,233,234,235,236],{"id":23,"depth":227,"text":24},{"id":57,"depth":227,"text":58},{"id":98,"depth":227,"text":99},{"id":116,"depth":227,"text":117},{"id":129,"depth":227,"text":130},{"id":155,"depth":227,"text":156},{"id":188,"depth":227,"text":189},{"id":211,"depth":227,"text":212},"Testing and TDD","2026-07-20T09:00:00.000Z","A green test that stayed green when we deleted the behaviour it was named after, and the single blunt question that exposed it.",false,"md","\u002Fimages\u002Fblog\u002Ftest-that-could-not-fail-hero.jpg",{},true,"\u002Fblog\u002Fthe-test-that-could-not-fail","EkoLite",7,{"title":5,"description":239},null,"blog\u002Fthe-test-that-could-not-fail",[252,253,254],"testing","nullables","websocket","lJh7ymDG0Tqfq0bJFDG7SpBl0Sg3KNV6Pubg4OHnAXs",[257,270,283,291,298,305,314,321,328,337,345,354,361,368,381,390,397,404,413,421,429,439,446,454,462,468,474,483,491,501,509,516,527,535,541,551,560,568,576,584,592,599,607,615,622,628,635,644,652,659,661,670,676,684,690,698,704,713,720,727,735,741,747,753],{"path":258,"title":259,"description":260,"date":261,"author":6,"image":262,"readingMinutes":263,"category":264,"project":265,"tags":266,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fa-branch-is-inventory","A Branch Is Inventory","Unintegrated code is stock in a warehouse, and it depreciates. Our branch was one merge behind the trunk and that was enough for a conflict. What waiting costs.","2026-07-09T09:38:00.000Z","\u002Fimages\u002Fblog\u002Fa-branch-is-inventory-hero.jpg",6,"Craft and Practice","The Dojo",[267,268,269],"git","continuous-integration","craft",{"path":271,"title":272,"description":273,"date":274,"author":6,"image":249,"readingMinutes":247,"category":275,"project":276,"tags":277,"draft":240,"series":281,"seriesOrder":282},"\u002Fblog\u002Fa-map-drawn-by-the-build","A Map Drawn by the Build","Thirty six of our pages had never been visited by Google because nothing said they existed. The sitemap is drawn from the build, and it found a draft.","2026-08-22T20:30:00.000Z","Shipping and Operations","ekohacks.com",[278,279,280],"seo","operations","nuxt","From Invisible to Findable",3,{"path":284,"title":285,"description":286,"date":287,"author":6,"image":249,"readingMinutes":263,"category":275,"project":276,"tags":288,"draft":240,"series":281,"seriesOrder":290},"\u002Fblog\u002Fa-privacy-policy-you-can-diff","A Privacy Policy You Can Diff","Our legal pages are files in the same repository as the site. Each now lists every change to its text, with the date and the diff, drawn from git at build time.","2026-08-23T16:00:00.000Z",[289,279,280],"privacy",9,{"path":292,"title":293,"description":294,"date":295,"author":6,"image":296,"readingMinutes":247,"category":237,"project":246,"tags":297,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fan-off-switch-not-a-disguise","An Off Switch, Not a Disguise","A Nullable differs from a stub in whether production code can tell the fake is there. Finding the word fixed a socket wrapper that had been lying to its tests.","2026-07-20T09:05:00.000Z","\u002Fimages\u002Fblog\u002Fan-off-switch-not-a-disguise-hero.jpg",[253,252,254],{"path":299,"title":300,"description":301,"date":302,"author":6,"image":303,"readingMinutes":304,"category":264,"project":249,"tags":249,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fbuilding-engineering-teams-that-learn-together","Building Engineering Teams That Learn Together","The strongest engineering teams are not the ones with the most talented individuals. They are the ones that have learned how to learn together.","2026-02-05T09:00:00.000Z","\u002Fimages\u002Fblog\u002Fteams-that-learn-together-hero.jpg",4,{"path":306,"title":307,"description":308,"date":309,"author":6,"image":310,"readingMinutes":304,"category":275,"project":265,"tags":311,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fderived-state-recompute","The Fix Was Merged, but the Numbers Were Still Wrong","We fixed a scoring bug, reviewed it, merged it, and production still showed the old numbers. Derived data is a cache that goes stale, and it must be recomputed.","2026-07-08T21:43:55.000Z","\u002Fimages\u002Fblog\u002Fderived-state-recompute-hero.jpg",[279,312,313],"deployment","idempotency",{"path":315,"title":316,"description":317,"date":318,"author":319,"image":320,"readingMinutes":263,"category":275,"project":249,"tags":249,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fdockerising-nestjs-nuxt-monorepo-with-pnpm","Dockerising a NestJS and Nuxt Monorepo with pnpm","Dockerising a NestJS and Nuxt monorepo with pnpm: separate multi stage Dockerfiles for development and production, one Compose file, reproducible environments.","2026-02-09T09:00:00.000Z","Ogochukwu Okpala","\u002Fimages\u002Fblog\u002Fdocker-monorepo-hero.png",{"path":322,"title":323,"description":324,"date":325,"author":6,"image":249,"readingMinutes":247,"category":275,"project":276,"tags":326,"draft":240,"series":281,"seriesOrder":327},"\u002Fblog\u002Ffifty-posts-and-an-empty-robots-txt","Fifty Posts and an Empty robots.txt","Our site scored 100 for SEO and nobody could find it. The audit that started a series on making a site findable, and on what measuring visitors costs them.","2026-08-21T14:30:00.000Z",[278,279,269],0,{"path":329,"title":330,"description":331,"date":332,"author":6,"image":333,"readingMinutes":304,"category":264,"project":265,"tags":334,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Ffrom-convention-to-plugin","From Convention to Plugin","A five step procedure for webhook signature checks, easy to forget and silent when you did, became one Fastify plugin. The safe path is now impossible to skip.","2026-04-17T08:26:12.000Z","\u002Fimages\u002Fblog\u002Ffrom-convention-to-plugin-hero.jpg",[269,335,336],"fastify","security",{"path":338,"title":339,"description":340,"date":341,"author":6,"image":342,"readingMinutes":343,"category":275,"project":265,"tags":344,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fintegrate-against-the-trunk","Integrate Against the Trunk, Not Your Copy of It","A rebase onto a stale local main succeeds, looks resolved, and leaves the pull request conflicting. Everything on your machine is a photograph of the trunk.","2026-07-09T09:22:00.000Z","\u002Fimages\u002Fblog\u002Fintegrate-against-the-trunk-hero.jpg",5,[267,268,279],{"path":346,"title":347,"description":348,"date":349,"author":6,"image":350,"readingMinutes":304,"category":264,"project":265,"tags":351,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Flandmines","Landmines","Most CLAUDE.md files grow because documenting a convention feels like progress. Ours holds landmines only: facts the code cannot tell you, costly to step on.","2026-04-17T10:15:06.000Z","\u002Fimages\u002Fblog\u002Flandmines-hero.jpg",[269,352,353],"documentation","ai-agents",{"path":355,"title":356,"description":357,"date":358,"author":6,"image":359,"readingMinutes":263,"category":275,"project":265,"tags":360,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fnever-break-the-build","Never Break the Build","If the software worked five minutes ago, only five minutes of change can be to blame. What a green trunk buys, who pays for it, and a check that could not fail.","2026-07-09T09:54:00.000Z","\u002Fimages\u002Fblog\u002Fnever-break-the-build-hero.jpg",[268,279,252],{"path":362,"title":363,"description":364,"date":365,"author":6,"image":249,"readingMinutes":247,"category":275,"project":276,"tags":366,"draft":240,"series":281,"seriesOrder":227},"\u002Fblog\u002Fone-address-per-page","One Address per Page","Every page on our site answered with a redirect before it answered with a page. How we made the build emit one address, declare it, and check it live.","2026-08-22T18:30:00.000Z",[278,279,280,367],"netlify",{"path":369,"title":370,"description":371,"date":372,"author":6,"image":373,"readingMinutes":304,"category":374,"project":375,"tags":376,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fone-command-three-policies","One Command, Three Policies, Zero Duplication","Preflight, cut and ship each worked alone before one command composed them. Why the composition layer is forty lines, and how the scrollback became the record.","2026-07-21T17:20:00.000Z","\u002Fimages\u002Fblog\u002Fone-command-three-policies-hero.jpg","Design and Architecture","The EkoHacks CLI",[377,378,379,380],"composition","cli-design","narration","encoding-the-release",{"path":382,"title":383,"description":384,"date":385,"author":6,"image":386,"readingMinutes":247,"category":374,"project":246,"tags":387,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fone-way-to-fail","One Way to Fail","Closing the convergence so an upload fails in the same error shape as every other call, mostly by deleting the code that made the two shapes differ.","2026-07-20T10:05:00.000Z","\u002Fimages\u002Fblog\u002Fone-way-to-fail-hero.jpg",[388,253,389],"design","error handling",{"path":391,"title":392,"description":393,"date":394,"author":6,"image":395,"readingMinutes":263,"category":275,"project":265,"tags":396,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fready-to-ship-not-ready-to-release","Ready to Ship, Not Ready to Release","Most teams hide a delay between saying done and being able to ship. Being ready to release at any moment, and why our deploy ships the commit that was tested.","2026-07-09T10:02:00.000Z","\u002Fimages\u002Fblog\u002Fready-to-ship-hero.jpg",[268,312,279],{"path":398,"title":399,"description":400,"date":401,"author":6,"image":402,"readingMinutes":263,"category":264,"project":265,"tags":403,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Frebase-or-merge-is-the-wrong-argument","Rebase or Merge Is the Wrong Argument","Both commands reconcile the same commits and hit the same conflicts. What differs is when you resolve them. The loud argument hides a quiet one: long branches.","2026-07-09T09:30:00.000Z","\u002Fimages\u002Fblog\u002Frebase-or-merge-hero.jpg",[267,268,269],{"path":405,"title":406,"description":407,"date":408,"author":6,"image":409,"readingMinutes":282,"category":374,"project":265,"tags":410,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Freconstructing-the-past","Reconstructing the Past","Connect a repository with a year of history and a trend curve reaches back months. How a system shows a past it never saw, recomputed from one source of truth.","2026-07-07T21:26:35.000Z","\u002Fimages\u002Fblog\u002Freconstructing-the-past-hero.jpg",[388,411,412],"data","onboarding",{"path":414,"title":415,"description":416,"date":417,"author":6,"image":249,"readingMinutes":263,"category":237,"project":375,"tags":418,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fretiring-a-board","Retiring a Board: User Testing a CLI Before You Ship It","An afternoon of user testing against a real Linear board, and the four findings a green test suite could never have written.","2026-08-01T10:20:00.000Z",[419,253,378,420],"user-testing","linear",{"path":422,"title":423,"description":424,"date":425,"author":6,"image":426,"readingMinutes":343,"category":237,"project":265,"tags":427,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fretrofitting-testing-without-mocks","Retrofitting Testing Without Mocks","The server had 37 vi.mock calls and no test that touched the database. Five steps to remove every mock with Nullables: real logic, real data, sociable tests.","2026-04-17T12:30:37.000Z","\u002Fimages\u002Fblog\u002Fretrofitting-without-mocks-hero.jpg",[252,253,428],"mocks",{"path":430,"title":431,"description":432,"date":433,"author":6,"image":434,"readingMinutes":304,"category":275,"project":375,"tags":435,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Frun-it-by-hand-until-it-is-boring","Run It by Hand Until It Is Boring","Releasing EkoLite was a document before it was a command, and stayed one until running it by hand had nothing left to teach us. Why we automate operations last.","2026-07-21T17:00:00.000Z","\u002Fimages\u002Fblog\u002Frun-it-by-hand-hero.jpg",[436,437,438,380],"automation","releasing","process",{"path":440,"title":441,"description":442,"date":408,"author":6,"image":443,"readingMinutes":282,"category":275,"project":265,"tags":444,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fsafe-to-run-twice","Safe to Run Twice","Connect the same repository twice, or let a webhook redeliver an event. Idempotency in two halves: writes through a natural key, side effects behind a ledger.","\u002Fimages\u002Fblog\u002Fsafe-to-run-twice-hero.jpg",[279,313,445],"webhooks",{"path":447,"title":448,"description":449,"date":450,"author":6,"image":249,"readingMinutes":263,"category":275,"project":265,"tags":451,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fsource-or-execute","Source or Execute","Our dojo laptops are provisioned by one bash script of nearly twelve hundred lines. Splitting it into modules is two decisions, not one, and we chose a hybrid.","2026-08-01T09:00:00.000Z",[452,453,377,279],"shell","provisioning",{"path":455,"title":456,"description":457,"date":249,"author":458,"image":459,"readingMinutes":460,"category":374,"project":461,"tags":249,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fstage-then-transform-why-your-crawler-should-never-touch-production-tables","Stage, Then Transform: Crawlers Stay Out of Production","Where data from an external source lands first decides whether the pipeline still works in six months. The staging pattern, shown with a crawler we build now.","EkoHacks","\u002Fimages\u002Fblog\u002Fstage-then-transform-hero.jpg",1,"Propi",{"path":463,"title":464,"description":465,"date":466,"author":6,"image":249,"readingMinutes":247,"category":237,"project":276,"tags":467,"draft":240,"series":281,"seriesOrder":343},"\u002Fblog\u002Ftelling-the-machine-what-the-page-is","Telling the Machine What the Page Is","Structured data on every post, and a test that runs after each build and fails it when a page lacks what a crawler needs. First run: forty nine problems.","2026-08-22T23:30:00.000Z",[278,252,280],{"path":469,"title":470,"description":471,"date":472,"author":6,"image":473,"readingMinutes":227,"category":237,"project":249,"tags":249,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Ftest-driven-development-is-not-about-tests","Test Driven Development Is Not About Tests","TDD is widely misunderstood. It is not a testing strategy. It is a design tool that produces simpler, more focused code by making you think before you type.","2026-02-01T09:00:00.000Z","\u002Fimages\u002Fblog\u002Ftdd-not-about-tests-hero.jpg",{"path":475,"title":476,"description":477,"date":478,"author":6,"image":479,"readingMinutes":343,"category":237,"project":375,"tags":480,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Ftesting-a-release-without-releasing","Testing a Release Without Releasing","How to test watch CI until green, then merge, hundreds of times a day with no CI in sight. Nullables, output trackers, and a fake that copies GitHub shapes.","2026-07-21T17:10:00.000Z","\u002Fimages\u002Fblog\u002Ftesting-a-release-without-releasing-hero.jpg",[253,481,482,380],"testing-without-mocks","james-shore",{"path":484,"title":485,"description":486,"date":487,"author":6,"image":249,"readingMinutes":263,"category":237,"project":265,"tags":488,"draft":240,"series":490,"seriesOrder":460},"\u002Fblog\u002Fthe-best-coverage-number-in-the-room","The Best Coverage Number in the Room","Same commit, same spec, same test count. The mocked suite ran 5.6 times faster, covered 3.5 fewer points of real code, and posted the best branch coverage.","2026-08-27T12:00:00.000Z",[252,253,428,489],"coverage","What Mocks Cost",{"path":492,"title":493,"description":494,"date":495,"author":6,"image":496,"readingMinutes":343,"category":374,"project":246,"tags":497,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fthe-bug-that-resolved-successfully","The Bug That Resolved Successfully","A bug that threw no error and dropped every document because the client guessed a collection from a name. The fix was one honest field on the ready message.","2026-07-05T10:16:16.000Z","\u002Fimages\u002Fblog\u002Fbug-resolved-successfully-hero.jpg",[498,499,252,500],"protocol","pubsub","tdd",{"path":502,"title":503,"description":504,"date":505,"author":6,"image":506,"readingMinutes":263,"category":237,"project":246,"tags":507,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fthe-clock-that-learned-to-walk","The Clock That Learned to Walk","A stub clock that teleported to the end of the window instead of passing through time, so a timer set inside another never fired. A real clock model fixed it.","2026-07-07T22:44:42.000Z","\u002Fimages\u002Fblog\u002Fthe-clock-that-learned-to-walk-hero.jpg",[253,252,508],"refactoring",{"path":510,"title":511,"description":512,"date":513,"author":6,"image":514,"readingMinutes":343,"category":264,"project":265,"tags":515,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fthe-conflict-nobody-wrote","The Conflict Nobody Wrote","A pull request conflicted in a file neither author had typed into. A generated document recorded test line numbers, both branches shifted them, git merges text.","2026-07-09T09:14:00.000Z","\u002Fimages\u002Fblog\u002Fthe-conflict-nobody-wrote-hero.jpg",[267,268,269],{"path":517,"title":518,"description":519,"date":520,"author":6,"image":521,"readingMinutes":522,"category":275,"project":265,"tags":523,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fthe-container-that-wasnt-there","The Container That Wasn't There","Production served HTML where the browser asked for JavaScript, and nothing in our code had changed. A week debugging a platform we cannot see inside.","2026-07-17T14:30:00.000Z","\u002Fimages\u002Fblog\u002Fcontainer-that-wasnt-there-hero.jpg",8,[279,524,525,526],"debugging","european-tech","community",{"path":528,"title":529,"description":530,"date":531,"author":6,"image":532,"readingMinutes":263,"category":374,"project":246,"tags":533,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fthe-design-we-never-drew","The Design We Never Drew","How the upload path in EkoLite arrived rather than being drawn, and the two habits, test first and integrate always, that turn an emerging shape into a design.","2026-07-20T10:00:00.000Z","\u002Fimages\u002Fblog\u002Fthe-design-we-never-drew-hero.jpg",[388,500,534],"continuous integration",{"path":536,"title":537,"description":538,"date":539,"author":6,"image":540,"readingMinutes":304,"category":264,"project":249,"tags":249,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fthe-dojo-way-deliberate-practice-for-software-developers","The Dojo Way: Deliberate Practice for Software Developers","Most developers learn on the job, and the job rarely teaches the fundamentals. The Dojo borrows from martial arts to make a space for deliberate practice.","2026-01-22T09:00:00.000Z","\u002Fimages\u002Fblog\u002Fthe-dojo-way-hero.jpg",{"path":542,"title":543,"description":544,"date":545,"author":6,"image":546,"readingMinutes":247,"category":264,"project":265,"tags":547,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fthe-done-column-is-a-syllabus","The Done Column Is a Syllabus","We asked Linear for every story our team has ever finished, all 187 of them, and read the column end to end. It turned out to be the course we teach.","2026-07-31T09:00:00.000Z","\u002Fimages\u002Fblog\u002Fdone-column-syllabus-hero.jpg",[548,549,550,420],"graphql","stories","xp",{"path":552,"title":553,"description":554,"date":555,"author":6,"image":556,"readingMinutes":343,"category":237,"project":375,"tags":557,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fthe-failing-test-is-the-review","The Failing Test Is the Review","Every behaviour in the CLI landed as two commits: a red test, then the code that makes it green. Reviewing intent before implementation, and why red never bent.","2026-07-21T17:05:00.000Z","\u002Fimages\u002Fblog\u002Fthe-failing-test-is-the-review-hero.jpg",[500,558,559,380],"red-first","code-review",{"path":561,"title":562,"description":563,"date":564,"author":6,"image":565,"readingMinutes":343,"category":275,"project":375,"tags":566,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fthe-first-real-run-said-no","The First Real Run Said No","The tool's first real outing ended with FAIL and a stopped rail, and we count it as a success. A naive sounding question then uncovered a quiet failure mode.","2026-07-21T17:25:00.000Z","\u002Fimages\u002Fblog\u002Fthe-first-real-run-said-no-hero.jpg",[437,567,267,380],"preflight",{"path":569,"title":570,"description":571,"date":572,"author":6,"image":573,"readingMinutes":343,"category":275,"project":246,"tags":574,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fthe-five-second-goodbye","The Five Second Goodbye","A shutdown that arms its own deadline so a hung close cannot strand the process, with the policy lifted out of the boot shell so every decision can be tested.","2026-07-05T12:03:00.000Z","\u002Fimages\u002Fblog\u002Fthe-five-second-goodbye-hero.jpg",[575,253,252],"shutdown",{"path":577,"title":578,"description":579,"date":580,"author":6,"image":581,"readingMinutes":263,"category":275,"project":246,"tags":582,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fthe-framework-in-the-description-field","The Framework in the Description Field","Our package called itself a framework, but it could not be installed. The fix was to stop trusting the build and become the stranger who installs the tarball.","2026-07-08T17:28:39.000Z","\u002Fimages\u002Fblog\u002Fframework-description-field-hero.jpg",[583,252,269],"packaging",{"path":585,"title":586,"description":587,"date":588,"author":6,"image":589,"readingMinutes":304,"category":374,"project":375,"tags":590,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fthe-gate-always-asks","The Gate Always Asks","A release automates things you cannot take back, so which of them may a machine do without asking a human? Pauses, and a yes flag that opens no door.","2026-07-21T17:15:00.000Z","\u002Fimages\u002Fblog\u002Fthe-gate-always-asks-hero.jpg",[436,591,388,380],"human-in-the-loop",{"path":593,"title":594,"description":595,"date":596,"author":6,"image":597,"readingMinutes":263,"category":275,"project":265,"tags":598,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fthe-gate-went-red-and-it-was-right","The Gate Went Red, and It Was Right","We added the missing check on our API specification and it failed on its first run, because the file was never a function of the source. A clean machine saw it.","2026-07-09T10:10:00.000Z","\u002Fimages\u002Fblog\u002Fgate-went-red-hero.jpg",[268,279,269],{"path":600,"title":601,"description":602,"date":603,"author":6,"image":604,"readingMinutes":263,"category":275,"project":246,"tags":605,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fthe-knock-and-the-axe","The Knock and the Axe","SIGTERM is a request your process can hear and SIGKILL is removal it never sees, and the fixed window between them is exactly where graceful shutdown lives.","2026-07-20T12:00:00.000Z","\u002Fimages\u002Fblog\u002Fthe-knock-and-the-axe-hero.jpg",[575,279,606],"signals",{"path":608,"title":609,"description":610,"date":611,"author":6,"image":612,"readingMinutes":522,"category":264,"project":246,"tags":613,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fthe-line-nothing-could-see","The Line Nothing Could See","Reviewing the reasoning under a refactor, not only the diff. Ask of every line what can observe it, and back each review claim with a test the author can run.","2026-07-07T22:43:00.000Z","\u002Fimages\u002Fblog\u002Fthe-line-nothing-could-see-hero.jpg",[559,508,614,252],"teaching",{"path":616,"title":617,"description":618,"date":619,"author":6,"image":620,"readingMinutes":263,"category":374,"project":246,"tags":621,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fthe-long-way-round","The Long Way Round","The count a method computes reaches the screen not through its return value but by being written into a collection a client subscribes to, EkoLite's channel.","2026-07-20T11:05:00.000Z","\u002Fimages\u002Fblog\u002Fthe-long-way-round-hero.jpg",[388,499,253],{"path":623,"title":624,"description":625,"date":626,"author":6,"image":249,"readingMinutes":247,"category":275,"project":276,"tags":627,"draft":240,"series":281,"seriesOrder":460},"\u002Fblog\u002Fthe-page-that-existed-only-when-someone-linked-to-it","The Page That Existed Only When Someone Linked to It","Our blog vanished in production while passing every local check. How we found the cause, fixed it twice, and how the fix gave every page a twin.","2026-08-22T16:00:00.000Z",[278,279,280],{"path":629,"title":630,"description":631,"date":632,"author":6,"image":633,"readingMinutes":522,"category":275,"project":265,"tags":634,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fthe-previews-that-served-production","The Previews That Served Production","Two weeks after the container that was not there, the same impossible error came back. The orphans were our own pull request previews. One diagnosis was wrong.","2026-07-31T17:00:00.000Z","\u002Fimages\u002Fblog\u002Fpreviews-served-production-hero.jpg",[279,524,525,526],{"path":636,"title":637,"description":638,"date":639,"author":6,"image":640,"readingMinutes":304,"category":264,"project":375,"tags":641,"draft":244,"series":249,"seriesOrder":249},"\u002Fblog\u002Fthe-release-is-the-acceptance-test","The Release Is the Acceptance Test","Not a green suite, not a good demo: a real version of a real package, live on npm, shipped by the tool. What red first tests proved, and what only reality did.","2026-07-21T17:35:00.000Z","\u002Fimages\u002Fblog\u002Frelease-acceptance-test-hero.jpg",[437,642,643,380],"dogfooding","acceptance-testing",{"path":645,"title":646,"description":647,"date":648,"author":6,"image":649,"readingMinutes":247,"category":237,"project":246,"tags":650,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fthe-script-that-never-ran","The Script That Never Ran","Joining two abilities EkoLite already had, a method that runs and a runner that shells out, and proving the join with a nulled runner before any script exists.","2026-07-20T11:00:00.000Z","\u002Fimages\u002Fblog\u002Fthe-script-that-never-ran-hero.jpg",[252,253,651],"scriptrunner",{"path":653,"title":654,"description":655,"date":656,"author":6,"image":249,"readingMinutes":522,"category":275,"project":276,"tags":657,"draft":240,"series":281,"seriesOrder":263},"\u002Fblog\u002Fthe-small-things-the-crawler-notices","The Small Things the Crawler Notices","Speed is the part of findability you can measure on a laptop. Fonts and images we shipped without looking, an error page we did not have, and the numbers after.","2026-08-23T10:00:00.000Z",[278,279,658],"performance",{"path":245,"title":5,"description":239,"date":238,"author":6,"image":242,"readingMinutes":247,"category":237,"project":246,"tags":660,"draft":240,"series":249,"seriesOrder":249},[252,253,254],{"path":662,"title":663,"description":664,"date":665,"author":458,"image":666,"readingMinutes":263,"category":264,"project":667,"tags":668,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fthe-website-that-told-the-same-story-twice","The Website That Told the Same Story Twice","Our home and story pages felt like one story across two screens. Rather than argue by taste, we modelled our words as data and asked a database one question.","2026-06-06","\u002Fimages\u002Fblog\u002Fwebsite-same-story-twice-hero.jpg","The Website",[269,669,411,614],"content",{"path":671,"title":672,"description":673,"date":495,"author":6,"image":674,"readingMinutes":343,"category":237,"project":246,"tags":675,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fthe-words-we-work-in","The Words We Work In","The vocabulary we test with, Nullable, embedded stub, stub object, output tracking, each grounded in real code so a review points at a line, never at taste.","\u002Fimages\u002Fblog\u002Fthe-words-we-work-in-hero.jpg",[253,252,269,614],{"path":677,"title":678,"description":679,"date":665,"author":458,"image":680,"readingMinutes":304,"category":264,"project":667,"tags":681,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fthe-workflow-is-the-lesson","The Workflow Is the Lesson","We are moving our content out of a cloud CMS and into git. Why a school that teaches a keyboard first way of working should practise it in the open, and how.","\u002Fimages\u002Fblog\u002Fworkflow-lesson-hero.png",[682,267,614,683],"workflow","accessibility",{"path":685,"title":686,"description":687,"date":688,"author":6,"image":249,"readingMinutes":263,"category":237,"project":265,"tags":689,"draft":240,"series":490,"seriesOrder":227},"\u002Fblog\u002Ftwenty-six-more-tests-four-fewer-behaviours","Twenty Six More Tests, Four Fewer Behaviours","Removing the mocks grew the suite from 44 tests to 70 and quietly deleted four behaviours, every one of them a failure path. Test count is not coverage.","2026-08-28T09:00:00.000Z",[252,253,428,489],{"path":691,"title":692,"description":693,"date":694,"author":6,"image":695,"readingMinutes":247,"category":275,"project":265,"tags":696,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Ftwo-hands-on-one-lever","Two Hands on One Lever","Our deploy failed with a deploy already in progress while the commit it wanted to ship was going live. Anatomy of a benign collision and a trigger safe to lose.","2026-07-23T09:00:00.000Z","\u002Fimages\u002Fblog\u002Ftwo-hands-on-one-lever-hero.jpg",[279,313,697],"observability",{"path":699,"title":700,"description":701,"date":702,"author":6,"image":249,"readingMinutes":247,"category":237,"project":265,"tags":703,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Ftwo-hundred-and-fourteen-undecided","Two Hundred and Fourteen Undecided","An accessibility scanner found three faults on the dojo, we fixed them, and the next run came back clean. The clean run was the dangerous one.","2026-08-22T12:00:00.000Z",[683,252,388,269],{"path":705,"title":706,"description":707,"date":708,"author":6,"image":709,"readingMinutes":282,"category":374,"project":265,"tags":710,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Ftwo-validators-one-gate","Two Validators, One Gate","Validating one request body twice, with a Fastify schema and again with Zod, is not double safety. The second gate is unreachable. One validator; Zod for types.","2026-07-07T22:06:46.000Z","\u002Fimages\u002Fblog\u002Ftwo-validators-one-gate-hero.jpg",[388,711,712],"validation","typescript",{"path":714,"title":715,"description":716,"date":717,"author":6,"image":718,"readingMinutes":263,"category":275,"project":265,"tags":719,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fwe-are-all-doing-asynchronous-integration","We Are All Doing Asynchronous Integration","A continuous integration server is not continuous integration. The forge tests your work after you have moved on. Our build takes three minutes; no excuse.","2026-07-09T09:46:00.000Z","\u002Fimages\u002Fblog\u002Fasynchronous-integration-hero.jpg",[268,279,312],{"path":721,"title":722,"description":723,"date":724,"author":6,"image":249,"readingMinutes":247,"category":275,"project":276,"tags":725,"draft":240,"series":281,"seriesOrder":247},"\u002Fblog\u002Fwe-promised-not-to-watch-then-we-needed-to-see","We Promised Not to Watch. Then We Needed to See.","Our privacy policy said no analytics, and it was true. Adding a page counter meant reopening that promise in public: what we chose, what we refused, one commit.","2026-08-24T10:00:00.000Z",[289,279,726],"analytics",{"path":728,"title":729,"description":730,"date":731,"author":6,"image":732,"readingMinutes":263,"category":275,"project":375,"tags":733,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fwhat-one-real-release-taught-us","What One Real Release Taught Us in an Afternoon","Two crashes and one near miss within minutes of touching the real GitHub, because we had refused to fake it. The race we never met, the merge that never came.","2026-07-21T17:30:00.000Z","\u002Fimages\u002Fblog\u002Fone-real-release-hero.jpg",[437,734,253,380],"failure-modes",{"path":736,"title":737,"description":738,"date":739,"author":6,"image":249,"readingMinutes":263,"category":275,"project":276,"tags":740,"draft":240,"series":281,"seriesOrder":304},"\u002Fblog\u002Fwhat-the-link-looks-like-before-anyone-clicks","What the Link Looks Like Before Anyone Clicks","A link to any page on our site showed up as a bare rectangle of text. The share image is part of the page, and the build now puts one on every route.","2026-08-22T22:00:00.000Z",[278,279,280],{"path":742,"title":743,"description":744,"date":745,"author":6,"image":249,"readingMinutes":263,"category":237,"project":265,"tags":746,"draft":240,"series":490,"seriesOrder":282},"\u002Fblog\u002Fwhat-the-nullable-gave-back","What the Nullable Gave Back","One file, seven behaviours held fixed, the database swapped for a Nullable: about 180 times less time inside the tests, and coverage flat to two decimals.","2026-08-29T09:00:00.000Z",[252,253,428,489],{"path":748,"title":749,"description":750,"date":751,"author":6,"image":249,"readingMinutes":247,"category":275,"project":276,"tags":752,"draft":240,"series":281,"seriesOrder":522},"\u002Fblog\u002Fwhat-we-count-and-what-we-refuse-to-know","What We Count, and What We Refuse to Know","Every system that holds anything about a visitor, in one table in the privacy policy. Writing it found a form that no longer existed and one that wrote twice.","2026-08-23T14:00:00.000Z",[289,279,726],{"path":754,"title":755,"description":756,"date":757,"author":6,"image":758,"readingMinutes":304,"category":374,"project":249,"tags":249,"draft":240,"series":249,"seriesOrder":249},"\u002Fblog\u002Fwhy-good-software-starts-with-the-right-questions","Why Good Software Starts with the Right Questions","Before writing a line of code, the most impactful thing a team can do is ask better questions. Discovery is not a phase to rush. It is the foundation.","2026-01-15T09:00:00.000Z","\u002Fimages\u002Fblog\u002Fright-questions-hero.jpg",1787861096923]