Skip to main content

shield Testing Strategy

100% code coverage is a starting point, not a finish line. A test suite can achieve full coverage while still missing critical bugs. Pithos uses a multi-layered testing strategy where each level catches bugs that the others miss.


The Problem with Coverage Aloneโ€‹

Consider this function. It looks simple, but a single test can achieve 100% line coverage while completely missing a critical edge case:

function divide(a: number, b: number): number {
return a / b;
}

A single test achieves 100% coverage:

it("divides two numbers", () => {
expect(divide(10, 2)).toBe(5); // โœ… 100% coverage
});

But what happens with divide(10, 0)? The test passes, coverage is complete, yet the function silently returns Infinity. Coverage tells you code executed, not that it works correctly.


The Four Levelsโ€‹

Each level of testing answers a different question:

LevelPrefixQuestion AnsweredWhat It Catches
Standard(none)"Does each line execute?"Dead code, forgotten branches
Edge case`[๐ŸŽฏ]`"Are all API contracts tested?"Untested union types, undocumented behaviors
Mutation`[๐Ÿ‘พ]`"If someone changes this code, will a test fail?"Weak assertions, silent regressions
Property-based`[๐ŸŽฒ]`"Does it work for _any_ valid input?"Edge cases: `null`, `""`, `NaN`, huge arrays

Why This Order Mattersโ€‹

  1. Standard tests establish that your code runs
  2. Edge case tests ensure your API promises are kept
  3. Mutation tests verify your tests are actually checking something
  4. Property-based tests explore the input space you didn't think of

A Complete Example: evolveโ€‹

The evolve function applies transformation functions to object properties. Here's how each testing level contributes:

// evolve({ a: 5 }, { a: x => x * 2 }) โ†’ { a: 10 }

Standard Tests - "Does it work?"โ€‹

These tests verify the documented behavior:

it("applies transformation functions to properties", () => {
const result = evolve({ a: 5, b: 10 }, { a: (x: number) => x * 2 });
expect(result).toEqual({ a: 10, b: 10 });
});

it("handles nested transformations", () => {
const result = evolve(
{ nested: { value: 5 } },
{ nested: { value: (x: number) => x + 1 } }
);
expect(result).toEqual({ nested: { value: 6 } });
});

it("preserves properties without transformations", () => {
const result = evolve({ a: 1, b: 2 }, {});
expect(result).toEqual({ a: 1, b: 2 });
});

At this point, coverage might be 100%. But are the tests solid?

Edge Case Tests - "Is the API contract complete?"โ€‹

The evolve function accepts transformations as either:

  • A function that transforms the whole value
  • An object of nested transformations

Both branches must be tested explicitly:

it("[๐ŸŽฏ] applies function transformation to object value", () => {
// Tests the "transformation is a function" branch for object values
const result = evolve(
{ nested: { value: 5 } },
{ nested: (obj: { value: number }) => ({ value: obj.value * 2 }) }
);
expect(result).toEqual({ nested: { value: 10 } });
});

The [๐ŸŽฏ] prefix signals: "This test covers a specific API branch or documented behavior."

Mutation Tests - "Are my tests actually checking?"โ€‹

Stryker modifies your code and checks if tests fail. If they don't, you have a surviving mutant: a bug your tests would miss.

// Stryker might change this:
if (transformation === undefined) { ... }
// To this:
if (transformation !== undefined) { ... } // Mutant!

If no test fails, you need a targeted test:

it("[๐Ÿ‘พ] handles undefined transformation for nested object", () => {
const result = evolve(
{ nested: { value: 5 }, other: 10 },
{ other: (x: number) => x * 2 }
);
// This specifically tests the branch where transformation is undefined
expect(result).toEqual({ nested: { value: 5 }, other: 20 });
});

The [๐Ÿ‘พ] prefix signals: "This test exists to kill a specific mutant."

Property-Based Tests - "Does it work for any input?"โ€‹

Instead of testing specific values, test invariants that should hold for all inputs:

import { it as itProp } from "@fast-check/vitest";
import { safeObject } from "_internal/test/arbitraries";

// Invariant: without transformations, object is preserved
itProp.prop([safeObject()])(
"[๐ŸŽฒ] preserves object when no transformations",
(obj) => {
expect(evolve(obj, {})).toEqual(obj);
}
);

// Invariant: all keys are preserved
itProp.prop([safeObject()])("[๐ŸŽฒ] preserves all keys", (obj) => {
const result = evolve(obj, {});
expect(Object.keys(result).sort()).toEqual(Object.keys(obj).sort());
});

// Independence: evolve returns a new object
itProp.prop([safeObject()])("[๐ŸŽฒ] returns new object reference", (obj) => {
const result = evolve(obj, {});
if (Object.keys(obj).length > 0) {
expect(result).not.toBe(obj);
}
});

The [๐ŸŽฒ] prefix signals: "This test uses random inputs to verify an invariant."

Property-based tests found bugs in Pithos that hundreds of manual tests missed, particularly around edge cases like empty objects, objects with Symbol keys, and deeply nested structures.

The Resultโ€‹

Running the tests shows all levels working together:

Vitest output showing Pithos evolve function tests with unit, property, and mutation test prefixes

Each test has a clear purpose. No redundancy, no gaps.


When to Use Each Levelโ€‹

SituationRecommended Approach
New functionStart with standard tests, add property-based for invariants
Union types in APIAdd `[๐ŸŽฏ]` tests for each branch
Mutation score < 100%Add targeted `[๐Ÿ‘พ]` tests for surviving mutants
Complex input domainAdd `[๐ŸŽฒ]` tests with appropriate arbitraries
Bug reportWrite a failing test first, then fix

Toolsโ€‹

ToolPurposeDocumentation
VitestTest runner with TypeScript supportGuide
StrykerMutation testingDocs
fast-checkProperty-based testingTutorials

Running Testsโ€‹

Pithos provides several test commands depending on what you want to verify, from a quick full run to targeted mutation testing on a single file:

# Run all tests
pnpm test

# Run tests with coverage report
pnpm coverage

# Run mutation tests (entire project)
pnpm test:mutation

# Run mutation tests on a specific file
pnpm stryker run --mutate 'packages/pithos/src/arkhe/object/evolve.ts'

Coverage Goalsโ€‹

MetricTargetWhy
Code coverage100%Baseline: ensures all code executes
Mutation score100%Ensures tests actually verify behavior
Property-based tests1-5 per functionExplores edge cases automatically
Edge case coverageAll union types + `@note`Ensures API contracts are tested

The Philosophyโ€‹

"A test that passes when the code is wrong is worse than no test at all: it gives false confidence."

Each prefix tells a story:

  • No prefix: "This is expected behavior"
  • [๐ŸŽฏ]: "This covers a specific API branch"
  • [๐Ÿ‘พ]: "This kills a specific mutant"
  • [๐ŸŽฒ]: "This verifies an invariant holds for any input"

When you see a failing test, the prefix immediately tells you why that test exists and what kind of bug you're dealing with.


Related