DEV Community

Cover image for Testing That I Actually Run: A Small Pyramid
Bradley Matera
Bradley Matera

Posted on

Testing That I Actually Run: A Small Pyramid

The "Testing Guilt" Cycle

There is a cycle that every developer goes through. It starts with a vow: "This time, I will have 100% code coverage. I will write E2E tests for every button click. I will be a 'Responsible Engineer'."

Two months later, the CI pipeline takes 20 minutes to run. The E2E tests flake out randomly because a div moved 2 pixels. You start commenting out tests just to get a hotfix deployed. Eventually, you stop running npm test altogether.

I have abandoned more test suites than I care to admit.

The problem isn't the desire to test; it's the strategy. We often build an "Ice Cream Cone" of testing: massive, slow, expensive UI tests on top, with a tiny cone of unit tests at the bottom. This is unstable and exhausting.

In 2025, I flipped the script. I use a "Small Pyramid" strategy. It relies on a high volume of ultra-fast unit tests, a complete absence of complex E2E frameworks (for personal projects), and a rigorous, documented "Smoke Protocol."

This post is the blueprint for that stack.


Level 1: The Infrastructure (Why Vitest Won)

For the better part of a decade, Jest was the undisputed king of JavaScript testing. But as the ecosystem shifted toward ESM (ECMAScript Modules) and TypeScript, Jest started showing its age.

  • The Problem with Jest: Jest operates by overriding the Node.js require system. To make it work with modern TypeScript or Vite projects, you essentially have to configure a Babel pipeline just for your tests. It is slow, memory-hungry, and debugging "SyntaxError: Cannot use import statement outside a module" is a rite of passage I never want to repeat.
  • The Solution (Vitest): Vitest is a native Vite-powered test runner. It reads your existing vite.config.ts. It supports ESM out of the box. It uses Worker threads for true parallelism.

The Setup

We don't just install a library; we define a standard. The package.json is the contract.

Command:

npm install -D vitest @vitest/coverage-v8

Enter fullscreen mode Exit fullscreen mode

Configuration (package.json):

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage"
  }
}

Enter fullscreen mode Exit fullscreen mode

The Config File (vitest.config.ts):
We create a dedicated config to ensure our testing environment isolates side effects.

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'node', // Use 'jsdom' if testing React components
    include: ['src/**/*.test.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
    },
    // Fail the build if we accidentally leave a .only() in the code
    allowOnly: false, 
  },
});

Enter fullscreen mode Exit fullscreen mode

Why this matters:
Using vitest run (single pass) vs vitest (watch mode) is a crucial distinction for CI/CD pipelines. If you put vitest in your GitHub Action, it will hang forever waiting for input.


Level 2: The Unit Test (The Bedrock)

The bottom of the pyramid must be wide and stable. These tests verify Pure Logic.

A "Pure Function" is a function that, given the same input, always returns the same output and produces no side effects (no API calls, no DOM updates). These are the easiest to test and the most critical to verify.

Real World Example: Data Transformation

Imagine an e-commerce app where we need to format a messy API response into a clean UI object.

The Logic (src/utils/formatter.ts):

interface UserAPIResponse {
  first_name: string | null;
  last_name: string | null;
  created_at: string; // ISO Date string
  role: 'admin' | 'user' | 'guest';
}

interface UserUI {
  fullName: string;
  memberSince: string;
  isAdmin: boolean;
}

export function formatUser(user: UserAPIResponse): UserUI {
  const first = user.first_name ?? 'Guest';
  const last = user.last_name ?? '';

  // Format Date (simple implementation for demo)
  const date = new Date(user.created_at);
  const year = date.getFullYear();

  return {
    fullName: `${first} ${last}`.trim(),
    memberSince: isNaN(year) ? 'Unknown' : year.toString(),
    isAdmin: user.role === 'admin',
  };
}

Enter fullscreen mode Exit fullscreen mode

The Test Suite (src/utils/formatter.test.ts):

import { describe, it, expect } from 'vitest';
import { formatUser } from './formatter';

describe('formatUser utility', () => {
  it('combines names correctly', () => {
    const input = { 
      first_name: 'Jane', 
      last_name: 'Doe', 
      created_at: '2023-01-01', 
      role: 'user' 
    } as const;

    expect(formatUser(input).fullName).toBe('Jane Doe');
  });

  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);
  });

  it('handles invalid dates', () => {
      const input = { 
          first_name: 'Test', 
          last_name: 'User', 
          created_at: 'invalid-date', 
          role: 'user' 
      } as const;

      expect(formatUser(input).memberSince).toBe('Unknown');
  });
});

Enter fullscreen mode Exit fullscreen mode

The Lesson:
This suite runs in 4 milliseconds. It protects us against null pointer exceptions and ensures our UI never says "undefined undefined". This is the highest ROI (Return on Investment) coding you can do.


Level 3: The "Smoke Protocol" (The Human Element)

This is where I diverge from the "Best Practices" dogma.

Standard advice says "Automate Everything." But setting up Cypress or Playwright to click a "Login" button, handle Authentication tokens, deal with 2FA, and wait for animations to finish is a massive maintenance burden. For a solo developer or a small team, maintenance is the enemy.

Instead, I use a rigorous Manual Smoke Protocol.

This isn't just "clicking around." It is a standardized checklist committed to the repository that must be physically checked off before a release.

The Protocol File (docs/QA_SMOKE_CHECK.md):

# 🛑 Release Smoke Check Protocol
**Do not merge to main until all items are verified.**

## 1. Critical User Flows
- [ ] **Authentication:** Log out and Log back in using Google Auth.
- [ ] **Data Persistence:** Update the user profile name, refresh the page. Does the new name persist?
- [ ] **Payment Flow:** Go to /pricing, click "Buy", reach the Stripe Checkout hosted page. (Do not need to complete purchase).

## 2. Responsive Check
- [ ] **Mobile Menu:** Open on iPhone viewport (Chrome DevTools). Does the hamburger menu expand?
- [ ] **Grid Layout:** Resize window to 768px. Does the 3-column grid snap to 1-column?

## 3. The "Stupid" Check
- [ ] **Console Errors:** Open DevTools console. Are there any red text blocks on the homepage?
- [ ] **Links:** Click the "Contact Us" link in the footer. Does it 404?

Enter fullscreen mode Exit fullscreen mode

Why this works:
It forces you to look at your application. Automated tests pass silently. Manual tests force you to feel the latency, see the layout shifts, and notice the janky animations that a script would ignore.


The "Gap" Analysis: What Are We Missing?

To be an honest engineer, you must admit what you are not testing. In this "Small Pyramid" stack, we have deliberate gaps:

  1. Visual Regression: We are not using Percy or Chromatic to check pixel-perfect rendering.
  2. Risk: I might accidentally change the button color from blue to slightly-less-blue.
  3. Acceptance: I can live with this risk in exchange for development speed.

  4. Integration Tests: We are not mocking the full API and testing the connection between the Frontend and Backend components.

  5. Risk: The API contract might change (e.g., firstName becomes first_name) and the frontend will crash.

  6. Mitigation: This is caught during the Manual Smoke Check.

  7. Cross-Browser Testing: I am mostly testing on Chrome.

  8. Risk: Safari might render flexbox differently.

  9. Acceptance: 90% of my traffic is Chrome/Mobile Safari. The Smoke Check covers the Mobile Safari viewport.


Bonus: Automating the Bedrock (GitHub Actions)

We can't automate the Smoke Check easily, but we must automate the Unit Tests. If tests only run on your laptop, they don't exist.

Here is the exact workflow file I drop into every project.

File: `.github/workflows/test.yml`

name: Unit Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Install Dependencies
        run: npm ci

      - name: Run Vitest
        run: npm test

Enter fullscreen mode Exit fullscreen mode

This ensures that no code can be merged into main unless the math functions and data formatters are working perfectly.

Final Verdict

Testing is not binary. It is not "Tested" vs "Untested." It is a spectrum of confidence.

By using Vitest for the logic that must be correct (math, data formatting) and a Smoke Protocol for the things that must look right (layout, navigation), you achieve 95% of the confidence with 10% of the maintenance cost of a full E2E suite.

That is a trade I will take every single time.

Top comments (1)

Collapse
 
rkeeves profile image
rkeeves