Write effective, maintainable tests that catch real bugs and enable confident refactoring. Use when deciding what to test, reviewing test quality, or writing tests for Grove features...
Activate this skill when:
For technical implementation (Vitest syntax, mocking patterns, assertions), use the javascript-testing skill alongside this one.
"Write tests. Not too many. Mostly integration." โ Guillermo Rauch
This captures everything Grove believes about testing:
Write tests โ Automated tests are worthwhile. They enable confident refactoring, serve as documentation, and catch regressions before users do.
Not too many โ Tests have diminishing returns. The goal isn't coverage numbers. It's confidence. When you feel confident shipping, you have enough tests.
Mostly integration โ Integration tests catch real problems without being brittle. They test behavior users actually experience, not internal implementation.
"The more your tests resemble the way your software is used, the more confidence they can give you." โ Kent C. Dodds (Testing Library)
Ask yourself: Does this test fail when the feature breaks? If yes, it's valuable. If it only fails during refactors, it's testing implementation details.
A good test has these properties (Kent Beck's Test Desiderata):
| Property | What It Means |
|---|---|
| Behavior-sensitive | Fails when actual functionality breaks |
| Structure-immune | Doesn't break when you refactor safely |
| Deterministic | Same result every time, no flakiness |
| Fast | Gives feedback in seconds, not minutes |
| Clear diagnosis | When it fails, you know exactly what broke |
| Cheap to write | Effort proportional to code complexity |
Before writing a test, ask:
Not everything needs tests. Some things actively harm your codebase when tested.
| What | Why |
|---|---|
| Trivial code | Getters, setters, data models with no logic |
| Framework behavior | Trust that SvelteKit routing works |
| Implementation details | Internal state, private methods, CSS classes |
| One-off scripts | Maintenance cost exceeds value |
| Volatile prototypes | Requirements unclear, code will change |
| What | Approach |
|---|---|
| Configuration | Smoke test that it loads, not every option |
| Third-party integrations | Mock at boundaries, test your code's response |
| Visual design | Snapshot tests or visual regression, not unit tests |
| What | Why |
|---|---|
| Business logic | Core value of the application |
| User-facing flows | What users actually experience |
| Edge cases | Error states, empty states, boundaries |
| Bug fixes | Every bug becomes a test to prevent regression |
Modern JavaScript testing follows the Testing Trophy, not the old Testing Pyramid:
โญโโโโโโโโโโฎ
โ E2E โ โ Few: critical user journeys
โฐโโโโโฌโโโโโฏ
โญโโโโโโโโโโดโโโโโโโโโโฎ
โ Integration โ โ Many: this is where confidence lives
โฐโโโโโโโโโโฌโโโโโโโโโโฏ
โญโโโโโโโดโโโโโโโฎ
โ Unit โ โ Some: pure functions, algorithms
โฐโโโโโโโฌโโโโโโโฏ
โญโโโโโโโโโโโดโโโโโโโโโโโฎ
โ Static Analysis โ โ TypeScript, ESLint (always on)
โฐโโโโโโโโโโโโโโโโโโโโโโฏ
Static Analysis (TypeScript, ESLint)
Unit Tests
Integration Tests (THE SWEET SPOT)
E2E Tests (Playwright)
Every test should follow this pattern:
it("should reject invalid email during registration", async () => {
// Arrange: Set up the scenario
const invalidEmail = "not-an-email";
// Act: Do the thing
const result = await registerUser({ email: invalidEmail, password: "valid123" });
// Assert: Check the outcome
expect(result.success).toBe(false);
expect(result.error).toContain("email");
});
The Act section should be one line. If it's not, the test is probably doing too much.
Test names should describe the behavior, not the implementation:
Good names:
should reject registration with invalid emailshould show error message when API failsshould preserve draft when navigating awayBad names:
test email validation (what about it?)handleSubmit works (what does "works" mean?)test case 1 (no)Each test should have one reason to fail. If a test fails, you should immediately know what broke.
// Bad: Testing multiple things
it('should handle registration', async () => {
// Tests validation, API call, redirect, AND email sending
});
// Good: Focused tests
it('should reject invalid email format', ...);
it('should call API with valid data', ...);
it('should redirect after successful registration', ...);
it('should send welcome email after registration', ...);
Every trust boundary should have tests for both valid and invalid data:
Form actions: Submit with missing fields, wrong types, edge-case values โ verify parseFormData() returns structured errors (not crashes)
KV reads: Mock KV returning corrupted/stale JSON โ verify safeJsonParse() falls back to default
Cache reads: Mock cache service returning unexpected shapes โ verify createTypedCacheReader() uses fallback
Catch blocks: Trigger redirects and HTTP errors โ verify isRedirect()/isHttpError() route them correctly
Reference: Rootwork (@autumnsgrove/lattice/server) provides parseFormData, safeJsonParse, createTypedCacheReader, isRedirect, and isHttpError.
Integration tests are the heart of Grove's testing strategy. Here's how to write them well.
// Bad: Testing implementation
it("should set isLoading state to true", async () => {
const { component } = render(LoginForm);
await fireEvent.click(getByRole("button"));
expect(component.isLoading).toBe(true); // Testing internal state!
});
// Good: Testing user experience
it("should show loading indicator while logging in", async () => {
render(LoginForm);
await fireEvent.click(getByRole("button", { name: /sign in/i }));
expect(getByRole("progressbar")).toBeInTheDocument();
});
Query elements the way users find them:
// Priority order (best to worst):
getByRole("button", { name: /submit/i }); // How screen readers see it
getByLabelText("Email"); // Form fields
getByText("Welcome back"); // Visible text
getByTestId("login-form"); // Last resort
Mocks remove confidence in the integration. Use them sparingly:
// Over-mocked: False confidence
vi.mock("./api");
vi.mock("./validation");
vi.mock("./utils");
// You're testing... nothing real
// Better: Mock at boundaries
vi.mock("./external-api"); // Mock the network, not your code
// Let validation, utils, etc. run for real
Rule of thumb: If you're mocking something you wrote, reconsider.
Tests that break are telling you something. Listen.
If refactoring frequently breaks tests, your tests are testing the wrong things.
Every production bug should become a test:
This is one of the highest-value testing practices. It turns pain into protection.
โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
โ Many E2E tests โ โ Slow, brittle, expensive
โฐโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโฏ
โญโโโโโโดโโโโโโฎ
โ Few int. โ
โฐโโโโโโฌโโโโโโฏ
โญโโโโดโโโโฎ
โ Few โ
โ unit โ
โฐโโโโโโโโฏ
This is backwards. E2E tests are expensive. Integration tests give the best ROI.
// Testing implementation (bad)
expect(component.state.items).toHaveLength(3);
expect(handleClick).toHaveBeenCalledWith({ id: 1 });
// Testing behavior (good)
expect(getByRole("list").children).toHaveLength(3);
expect(getByText("Item added!")).toBeInTheDocument();
Chasing 100% coverage leads to bad tests:
// Written only to hit coverage, provides zero value
it("should have properties", () => {
const user = new User();
expect(user.email).toBeDefined();
expect(user.name).toBeDefined();
});
Coverage is a signal, not a goal. High coverage with bad tests is worse than moderate coverage with good tests.
Snapshots are useful for:
Snapshots are harmful for:
When asked to add tests, follow this workflow:
What does this feature do for users? Not how it's implementedโwhat value does it provide?
What would break if this feature failed? Those are your test cases.
Start with tests that exercise real user behavior. Add unit tests only for complex logic.
src/
โโโ lib/
โโโ features/
โโโ auth/
โโโ login.ts
โโโ login.test.ts โ Right next to the code
โโโ register.ts
npx vitest # Watch mode during development
npx vitest run # CI verification
| Situation | Action |
|---|---|
| New feature | Write integration tests for user-facing behavior |
| Bug fix | Write test that reproduces bug first, then fix |
| Refactoring | Run existing tests; if they break on safe changes, they're bad tests |
| "Need more coverage" | Add tests for uncovered behavior, not uncovered lines |
| Pure function/algorithm | Unit test it |
| API endpoint | Integration test with mocked external services |
| UI component | Component test with Testing Library |
| Critical user flow | E2E test with Playwright |
Use javascript-testing for:
When writing test descriptions, follow Grove voice:
Run linting and type checking before/after writing tests. Static analysis catches different bugs than tests do.
Before considering tests "done":
Good tests let you ship with confidence. That's the whole point.