I have abandoned more test suites than I care to admit. I would start with a vow to write 100% coverage, add a Cypress suite, then spend weeks debugging flaky tests because a div moved two pixels. Eventually I stopped running npm test altogether.

The problem was not a lack of desire. It was the strategy. I was building an ice cream cone: a few unit tests at the bottom and a massive layer of slow UI tests on top. That is exhausting.

Now I use a much smaller stack: fast unit tests for pure logic, and a short manual smoke protocol for the rest.

Why do my test suites keep failing or getting abandoned?

Most test suites get abandoned because they become a maintenance burden instead of a safety net. The common pattern is building an ice cream cone: a few unit tests at the bottom and a massive layer of slow, flaky UI tests on top. When tests break because a div moved two pixels, you eventually stop running them. The fix is a smaller, faster stack.

What is the best testing strategy for small and solo projects?

For solo and small projects, the best strategy is a small testing pyramid: fast unit tests with Vitest for pure logic like data transformations and formatters, plus a short manual smoke checklist for the rest. Skip E2E frameworks like Cypress and Playwright unless you have a team to maintain them. Automate the unit tests in CI and keep the smoke check manual.

How do I set up Vitest for unit testing in a Vite project?

Vitest is the easiest unit test runner for Vite projects because it reads your existing Vite config and supports ESM and TypeScript out of the box. Install it with npm install -D vitest, add test scripts to your package.json, and write focused tests on pure logic. Tests run in milliseconds and catch null values, bad date parsing, and wrong booleans.

Level 1: unit tests with vitest

For a while I used Jest, but ESM and TypeScript kept creating configuration friction. Vitest reads my existing Vite config, supports ESM out of the box, and runs fast.

Setup is minimal:

npm install -D vitest @vitest/coverage-v8
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}

I keep tests focused on pure logic: data transformations, formatters, anything where the same input should always produce the same output. For example:

import { describe, it, expect } from 'vitest';
import { formatUser } from './formatter';
describe('formatUser utility', () => {
it('handles null names gracefully', () => {
const input = {
first_name: null,
last_name: null,
created_at: '2023-01-01',
role: 'guest'
} as const;
expect(formatUser(input).fullName).toBe('Guest');
});
it('identifies admin privileges', () => {
const input = {
first_name: 'Admin',
last_name: 'User',
created_at: '2023-01-01',
role: 'admin'
} as const;
expect(formatUser(input).isAdmin).toBe(true);
});
});

These run in milliseconds. They catch the boring mistakes: null values, bad date parsing, wrong booleans. That is the highest return I get from testing.

Here are three real bugs that unit tests caught before they shipped:

  1. A formatDate function that parsed ISO strings with new Date(input) worked for valid dates but returned "Invalid Date" for empty strings instead of falling back to a default. The test fed it "" and the assertion failed loudly.
  2. A price formatter that was supposed to round to two decimals used Math.round instead of toFixed(2), so $19.995 became 20 instead of "20.00". The test caught the type mismatch — the function returned a number when the contract said string.
  3. A role-check helper returned true for the string "admin" but also for "administrator" because it used startsWith("admin"). The test asserted that only exact "admin" should pass, and it failed.

None of those are glamorous. But each one would have surfaced in production as a visible bug, and each took under five minutes to write a test for.

Execution process checkpoint illustration for this section.

Level 2: the manual smoke protocol

I do not run E2E tests on personal projects. Cypress and Playwright are powerful, but maintaining them for a solo project is often more work than the bugs they catch.

That said, I do use a small number of integration tests for the boundaries between modules — not full E2E, but tests that exercise two or three functions together. For example, a test that calls a fetchUser function (which hits a mocked fetch) and pipes the result through formatUser catches wiring bugs that a pure unit test on formatUser alone would miss. I keep these in Vitest alongside the unit tests using vi.mock() to stub the network layer.

Here is the breakdown of test types I actually use:

  • Unit tests (Vitest) — pure functions, formatters, parsers, role checks. Same input, same output. These are 90% of my test files.
  • Integration tests (Vitest with mocks) — two or three modules wired together, network stubbed. Maybe 10% of files. Example: fetchUser + formatUser + a cache layer.
  • E2E (manual smoke check) — the checklist below. No Playwright, no Cypress. I am the runner.

The one time I did add a Playwright suite for a client project, it caught a real production issue: a form submission flow that worked in Chrome but failed in Firefox because of a submit event ordering difference. That was worth the maintenance cost on a team. On a solo project, I would have caught the same thing in the manual smoke check the first time I tested in Firefox.

Instead I keep a short checklist in docs/QA_SMOKE_CHECK.md and walk through it before a release:

# Release smoke check
- Home page loads with no console errors.
- Primary user flow works end to end.
- Mobile menu opens and closes.
- Footer links do not 404.

Manual checks force me to look at the app. I notice layout shifts, slow interactions, and broken links that automated assertions would miss.

Level 3: CI for the unit tests only

I automate the fast part. The smoke check stays manual. My GitHub Actions workflow looks like this:

name: Unit Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm test

Nothing fancy. It just makes sure the unit tests run somewhere other than my laptop.

How testing fits into my workflow

The workflow is simple and I do not over-engineer it:

  1. Write the function. Get it working in the REPL or a scratch file.
  2. Write one or two unit tests for the edge cases I already thought about while writing it — null inputs, empty strings, boundary values.
  3. Run npm test locally. If it passes, commit.
  4. Push. CI runs the same vitest run command. If it fails, I get a red X on the commit and fix it before merging.
  5. Before a release, walk the smoke checklist manually in Chrome and Firefox.

I do not write tests first. I do not aim for a coverage number. I write tests for the functions where a bug would be embarrassing or hard to trace — formatters, parsers, permission checks, anything with money or dates in it. Everything else gets the smoke check.

Delivery workflow checkpoint illustration for this section.

What I deliberately skip

I am honest about the gaps. No visual regression testing. No full integration tests. Mostly Chrome-based checks.

Those are real risks, but for personal projects the maintenance cost is not worth it. I would rather ship and verify by hand than maintain a brittle E2E suite I stop running.

Closing

Testing is not binary. It is a confidence trade-off. I use fast unit tests for logic that must be right and a short smoke protocol for everything else. That gives me most of the confidence with a fraction of the maintenance.