Our workspace runs 1,129 tests and the app people actually hold has 67 of them
Munchable answers one question in a shop: does this packaged food fit the gut conditions you manage? You scan the barcode and get a verdict with the reasons under it. The conditions are at munchable.app/conditions, the recipe library is at munchable.app/recipes, and the same reasoning is published page by page at munchable.app/answers. The repo is a pnpm workspace: a React Native app through Expo, a Next.js site, and four shared packages. pnpm -r run test walks all of it. Here is the distribution, which I had never actually added up before writing this: packages/content 474 apps/web 358 packages/rules-engine 204 apps/mobile 67 packages/pricing 25 packages/reels 1 ----- 1,129 The app is 20 screens and 37 components, it is the only part of this that anybody touches with their hands, and it holds six per cent of the tests. That ratio is deliberate. This post is the argument for it, and the two places where the argument runs out. Eight files, 67 tests, 327 milliseconds The app's entire suite is eight files in one directory, src/lib/__tests__: allergens.test.ts 11 tests recipes.test.ts 12 scanGate.test.ts 12 history.test.ts 8 selection.test.ts 8 singleFlight.test.ts 8 region.test.ts 7 wipe.test.ts 1 The runner is Node: "test": "node --import tsx --test 'src/**/*.test.ts'" That is the whole testing stack. The app's devDependencies are @types/node and tsx, and nothing else. No Jest, no Vitest, no @testing-library/react-native, no react-test-renderer. Sixty-seven tests in 327 milliseconds, which is fast enough that the suite runs on instinct rather than on a decision. There is also no test double anywhere in it. Grep all eight files for mock, stub and fake and you get nothing. That is not discipline, it is a consequence: everything in those files is a pure function, so there is nothing to stand in for. What eight files are allowed to cover The list is short enough to read as a statement of what this app considers risky: The scan gate. What the hub does with one barcode read, including whether that read costs somebody one of their free scans. Charging twice for one scan is the kind of bug you only hear about from a user. The allergy wording. A declared allergy must always produce a group, must always name what it is talking about, and must never claim absence. The wording is the safety surface, so the wording is asserted, word by word. The de-duplicator. The camera reports the same barcode on every frame it can see it, and the speculative lookup and the confirmed one must be the same promise rather than two requests racing. Crop geometry. A rectangle a finger drew, on a preview that is aspect-filled, mapped into photo pixels. Eight tests including the pole cases, because this is pure arithmetic with an easy wrong answer. Locale parsing. The shapes iOS, Android and browsers actually hand us, because the paywall's currency is picked from this and a misread locale shows somebody the wrong money. Scan history, the recipe filter, and the data wipe. Every one of those is a decision that is expensive to get wrong and cheap to check. None of them has anything to do with how the app looks. The test that cannot import its subject The smallest file in the suite is the most interesting one. wipeLocalData is the function behind "delete my account": it names every storage key Munchable has ever written on the device and removes them. The list is written by hand, and one of the keys has a version suffix that gets bumped whenever the shape of a cached product changes, which is exactly how the two files drifted apart. Account deletion kept clearing v1 and v2 for a while after the app had moved to v3. A normal test would import both modules and compare. This one cannot, because both of them pull in AsyncStorage, which needs a native host that node --test does not have. So it reads them as text: const read = (path: string) => readFileSync(new URL(path, import.meta.url), 'utf8'); test('account deletion clears the product cache key the app actually writes', () => { const key = /STORE_KEY = '([^']+)'/.exec(read('../productCache.ts'))?.[1]; assert.ok(key, 'productCache.ts no longer declares STORE_KEY as a literal'); assert.match(read('../wipe.ts'), new RegExp(`'${key}'`), `wipe.ts does not clear ${key}`); }); Two things make this honest rather than a hack. The first is that the thing being checked is genuinely a text-level fact: one file declares a string literal, another file must contain that same literal. Importing would not make the assertion truer, it would just make it slower and require a phone. The second is the first assertion, which exists only to fail loudly when the premise stops holding. If somebody rewrites the cache key as a template literal or a computed constant, the regex stops matching and the test says productCache.ts no longer declares STORE_KEY as a literal rather than silently passing on a key it never found. A source-reading test without that guard is a test that quietly retires itself. I have written the same pattern on the website for the same reason: a metadata test there reads page files as text because importing one boots Supabase. The cache key's version suffix has its own story, and account deletion on the server side erases seven tables and eight string literals. Two tsconfigs, because the tests live in a different runtime The tests are the only TypeScript in the app that runs on Node rather than on a phone, and that shows up in the type checking: // tsconfig.json "include": ["**/*.ts", "**/*.tsx"], "exclude": ["**/__tests__/**"] // tsconfig.test.json "extends": "./tsconfig.json", "compilerOptions": { "types": ["node"] }, "include": ["src/**/__tests__/**/*.ts"], "exclude": [] And the script runs both: "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json" The app config extends Expo's base and knows about React Native. The test config is the same settings with Node's types added and the file set inverted. Keeping them in one project means either the app's code can see node:fs or the tests cannot see node:test, and the first of those is worse than a second config file. What is deliberately untested, and what that costs Zero of the 37 components have a test. Zero of the 20 screens have a test. There is no renderer in the dependency tree, so this is not a gap I could close by writing a few files. The reasoning: a render assertion on one of these screens would prove the element tree, and the element tree has never been the thing that was wrong. Two of the worst bugs this app has had were a verdict word painted over the badge beneath it, caused by a layout animation hoisting a view out of the flow, and a camera that kept refocusing because of a prop whose values read backwards. Both screens would have rendered a perfectly correct tree in a renderer that cannot see pixels or hold a lens. Both were obvious in two seconds to a person holding the phone. So the honest trade is: the app's logic is tested as pure functions, its appearance is tested by looking at it, and the 1,062 tests in the other packages exist because that is where the product's answers are computed and an answer is exactly the kind of thing a test can hold. Where the trade runs out is the class of bug that is neither logic nor appearance, but a missing attribute. I audited the app's accessibility props this week and found two icon-only controls with no role and no label, and four unannounced actions on the profile screen. A render test would catch those, and so would a lint rule over the JSX, and the lint rule is cheaper, faster and does not need a renderer. Same with the layout rule that fell out of measuring what the largest accessibility text size does to the app: no fixed height may contain text. That is a grep, not a test case. Which is roughly the conclusion. In an app this size the useful third category is not more tests, it is static rules about the shapes the code is allowed to take. Sixty-seven tests for the decisions, screenshots for the looks, and a short list of greps for the attributes that are easy to forget and invisible when they are missing. The parts you can check yourself The packages carrying most of those 1,129 tests are the ones whose output is public: munchable.app/answers is a page per ingredient and condition, munchable.app/hidden-gluten-ingredients is the reference-list version, and munchable.app/recipes is the recipe library the content package tests. If you want the app end of it, the scan flow is demonstrated on the landing page at munchable.app. If you run an Expo app, the one number worth getting is how long your suite takes without a framework in it. Ours is 327 milliseconds for the whole app, and that number is the reason nobody on this project has ever argued about whether to run the tests.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to