Facts first, in the order I found them.
ls -a | grep -iE 'github|husky|lint-staged|circle|travis' # (nothing) git log --all --oneline -- .github # (nothing) There has never been a CI configuration in this repository. No workflow, no pre-commit hook, no lint-staged. The test suite runs when I type npx vitest run, and at no other moment in the life of this project.
So I typed it.
Test Files 1 failed | 15 passed (16) Tests 2 failed | 264 passed (266) Duration 39.04s Two failures. Not from a change I had just made: from a test that had been red for some unknown number of days, in a repository where nothing was ever going to tell me.
The 253 and the 13
The suite has two completely different populations in it, and the aggregate number hides both.
Exclude one file and the shape becomes obvious:
npx vitest run --exclude '**/gameplay.integration.test.ts' # Test Files 15 passed (15) # Tests 253 passed (253) # Duration 629ms 253 tests, 629 milliseconds, no network, no DOM, no database. They are pure functions and rendered strings: pricing arithmetic across 24 currencies, the phrases our price copy is not allowed to use, the registry that owns 72 URLs, the metadata budget read out of route source files, the webhook's decision about which subscription a venue follows. 88 of them are about billing and not one of them calls Stripe, because the Stripe client is a handful of vi.fn()s.
Then there are 13 tests in one file that opens a real Postgres connection over the internet, and that file is where 38 of the 39 seconds go. Two of those 13 are the failures:
× ... across a full session 5008ms × ... many participants submitting in parallel 5003ms Error: Test timed out in 5000ms. 5008 and 5003 milliseconds. Those are not assertion failures, they are the default Vitest timeout, reached while waiting on a database round trip. The test is not wrong about the code. It is a network test wearing a unit test's clothes, with a unit test's patience.
And the database it reaches is a real hosted one shared with ordinary development rather than a disposable instance, so these tests insert rows that outlive them, and an interrupted run leaves state behind that the next run has to tolerate.
What the one real gate checks
Every push to the default branch triggers a Vercel build. That is the only automated process this project has, and the contents of package.json tell you what it does:
"scripts": { "test": "vitest run", "build": "next build" } next build type-checks the whole project, so a type error fails the deploy. It does not run vitest, and nothing else does either. The gate is "it compiles and 72 pages prerendered without throwing", which is a genuinely useful gate, and it would have caught none of the two failures above because those are a timeout in a test file that next build never imports.
The only visible artifact of that pipeline is stamped on every asset the site serves:
curl -s https://pub-trivia.app/ | grep -o 'dpl_[A-Za-z0-9]*' | head -1 That is the deployment id, repeated on every script and stylesheet URL and on the <html> element as data-dpl-id. It is version skew protection, and it is also a receipt: the whole build, type check and all, happened once, and that string is the proof.
What I am going to do about it
In order of effort, which is also the order of value here:
- Run the 253 on push. They need no secrets, no services and no database. A workflow with
pnpm installandvitest run --exclude '**/*.integration.test.ts'costs about 40 seconds of CI time and turns "red for an unknown number of days" into "red for one push". - Give the integration file its own project and its own timeout. Vitest supports multiple projects in one config; this file wants a 30 second timeout, a serial runner, and a database it is allowed to leave a mess in. Mixing it into the default run is why the suite's headline number went from 0.6 seconds to 39, and why nobody runs it casually.
- Make the deploy depend on step one. Vercel has an ignored-build-step hook that can run a command and skip the build on a non-zero exit. That is the cheapest honest gate available for a project with no CI budget.
What I am not going to do is add a coverage threshold. @vitest/coverage-v8 has been installed since the beginning and there is no script that calls it, which is a decent illustration of how much a number like that was ever going to matter here.
The bit worth generalising
A solo project ends up with the gates whose absence is visible. A broken build is visible: the site does not change. A red test is not visible at all, because the only thing that would have mentioned it is the thing you did not set up.
If you have a repository like this one, the useful question is not "do I have tests", it is "what happens to a failing test between now and the next time I feel like running them". Here the answer was "nothing, for days", and the fix is 15 lines of YAML.
Check it yourself
You cannot run our suite, but you can see the end of the pipeline:
# the one gate's receipt, on every asset URL curl -s https://pub-trivia.app/pricing | grep -o 'dpl_[A-Za-z0-9]*' | head -1 # and what it produced: 72 URLs, 70 of them prerendered curl -s https://pub-trivia.app/sitemap.xml | grep -c '<loc>' Then have a look at pub-trivia.app/pricing, which is where most of those 253 fast tests are pointed: prices in your own currency, the words around them checked by 28 assertions, and a plan change that 88 billing tests say will not leave you on nothing.
![Why Object.keys(user) Returns string[], Not (keyof User)[]: TypeScript Means It](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2q4w48b9bzs43tynyhsf.png)
