🌐 Translation: Translated from Korean.

I’ll translate this Korean blog post to English while preserving technical terms, code blocks, and maintaining SEO optimization. Let me start the translation:


TDD in Practice: Lessons Learned from Testing TypeScript with vitest

Executive Summary



  • Transition: From no-test development β†’ TDD (Test-Driven Development)
  • Tools: vitest + TypeScript (10x faster than Jest)
  • Results: 75 tests written, 90% faster bug detection, refactoring confidence gained
  • Real Project: WordPress blog automation CLI tool (production-ready)

1. Why I Started TDD

In the early stages of the project, I developed without test code.

“Let’s build it fast first, add tests later”

But reality was different.

The Pain of Test-less Development

Problem 1: Refactoring Fear

// ❌ Don't know what broke after refactoring
function validateTranslation(original, translated) {
  // 100 lines of complex logic...
  // Did my changes work properly? πŸ€”
}

Problem 2: Delayed Bug Detection

Timeline     Event
Day 1        Feature implementation complete
Day 3        Working on other features...
Day 7        User discovers bug "Translation looks weird"
Day 8        Start finding root cause (where did it break?)
Day 9        Debugging complete (total 2 days spent)

Problem 3: Missing Edge Cases

// ❌ Didn't think of these cases
validateTranslation('', ''); // empty string
validateTranslation('ν•œκΈ€', undefined); // undefined
validateTranslation(null, 'English'); // null

Decision: “I can’t develop like this anymore. Let’s learn TDD.”


2. Why I Chose vitest

There are many TypeScript testing tools. Why vitest?

2.1 Comparing Major Testing Frameworks

Tool Speed ESM Support TypeScript Setup Complexity
vitest ⚑⚑⚑ (10x) βœ… Native βœ… Perfect 🟒 Simple
Jest 🐒 Slow ⚠️ Experimental βœ… Good 🟑 Moderate
Mocha 🐒 Slow ❌ None ⚠️ Manual πŸ”΄ Complex

2.2 vitest’s Decisive Advantages

1. Overwhelming Speed

# Jest
βœ“ 75 tests in 8.5s

# vitest
βœ“ 75 tests in 0.8s (10x faster!)

2. Native ESM Support

// βœ… vitest: Just works
import { validateTranslation } from './validation.js';

// ❌ Jest: Configuration hell
// Add "type": "module" to package.json
// babel config, transform settings...

3. Perfect TypeScript Support

// βœ… Automatic type inference
import { describe, it, expect } from 'vitest';

describe('Validation', () => {
  it('should validate', () => {
    const result = validateTranslation(/* ... */);
    // result type automatically inferred!
  });
});

4. Jest-Compatible API

// If you have Jest experience, use it immediately
describe, it, expect, beforeEach, afterEach
vi.mock(), vi.spyOn() // Same as Jest's jest.mock()

3. vitest Setup and First Test

3.1 Installation and Configuration

# 1. Install vitest
pnpm add -D vitest

# 2. Add scripts to package.json
{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:ui": "vitest --ui"
  }
}

vitest.config.ts (optional):

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true, // Use describe, it, expect without imports
    environment: 'node',
  },
});

3.2 Writing Your First Test

Test Target (packages/core/src/validation.ts):

export function validateBasics(content: string): boolean {
  if (!content || content.trim().length === 0) {
    return false;
  }
  return true;
}

Test Code (packages/core/src/validation.test.ts):

import { describe, it, expect } from 'vitest';
import { validateBasics } from './validation';

describe('validateBasics', () => {
  it('should return false for empty content', () => {
    const result = validateBasics('');
    expect(result).toBe(false);
  });

  it('should return false for whitespace only', () => {
    const result = validateBasics('   ');
    expect(result).toBe(false);
  });

  it('should return true for valid content', () => {
    const result = validateBasics('Hello World');
    expect(result).toBe(true);
  });
});

Execution:

pnpm test

# Result
βœ“ src/validation.test.ts (3 tests) 5ms
  βœ“ validateBasics (3)
    βœ“ should return false for empty content
    βœ“ should return false for whitespace only
    βœ“ should return true for valid content

Test Files  1 passed (1)
     Tests  3 passed (3)

4. TDD Workflow in Practice: Red-Green-Refactor

The process of developing the validateTranslation function using TDD in a real project.

4.1 Red: Write a Failing Test

Requirement: “Translated content must have line count within 50-150% range of the original”

// validation.test.ts
describe('validateLineCount', () => {
  it('should pass if line count is within 50-150% range', () => {
    const original = 'Line 1\nLine 2\nLine 3\nLine 4'; // 4 lines
    const translated = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5'; // 5 lines (125%)

    const result = validateLineCount(original, translated);

    expect(result.isValid).toBe(true);
    expect(result.issues).toHaveLength(0);
  });

  it('should fail if line count is too low (<50%)', () => {
    const original = 'Line 1\nLine 2\nLine 3\nLine 4'; // 4 lines
    const translated = 'Line 1'; // 1 line (25%)

    const result = validateLineCount(original, translated);

    expect(result.isValid).toBe(false);
    expect(result.issues).toContainEqual({
      severity: 'error',
      message: expect.stringContaining('Line count too low')
    });
  });
});

Execution Result:

βœ— validateLineCount > should pass if line count is within 50-150% range
  ReferenceError: validateLineCount is not defined

πŸ”΄ Red: Test fails (function doesn’t exist)



4.2 Green: Minimal Code to Pass the Test

// validation.ts
export function validateLineCount(
  original: string,
  translated: string
): ValidationResult {
  const originalLines = original.split('\n').length;
  const translatedLines = translated.split('\n').length;
  const ratio = translatedLines / originalLines;

  const issues: ValidationIssue[] = [];

  if (ratio < 0.5) {
    issues.push({
      severity: 'error',
      message: `Line count too low: ${translatedLines} lines (${(ratio * 100).toFixed(1)}% of original ${originalLines} lines)`
    });
  }

  if (ratio > 1.5) {
    issues.push({
      severity: 'error',
      message: `Line count too high: ${translatedLines} lines (${(ratio * 100).toFixed(1)}% of original ${originalLines} lines)`
    });
  }

  return {
    isValid: issues.length === 0,
    issues
  };
}

Execution Result:

βœ“ validateLineCount (2 tests) 3ms
  βœ“ should pass if line count is within 50-150% range
  βœ“ should fail if line count is too low (<50%)

🟒 Green: Test passes!

4.3 Refactor: Code Improvement

// validation.ts (refactored)
const LINE_COUNT_MIN_RATIO = 0.5;
const LINE_COUNT_MAX_RATIO = 1.5;

export function validateLineCount(
  original: string,
  translated: string
): ValidationResult {
  const originalLines = countLines(original);
  const translatedLines = countLines(translated);
  const ratio = translatedLines / originalLines;

  const issues = [
    ...checkLineCountTooLow(ratio, originalLines, translatedLines),
    ...checkLineCountTooHigh(ratio, originalLines, translatedLines),
  ];

  return {
    isValid: issues.length === 0,
    issues
  };
}

// Helper functions
function countLines(content: string): number {
  return content.split('\n').length;
}

function checkLineCountTooLow(
  ratio: number,
  originalLines: number,
  translatedLines: number
): ValidationIssue[] {
  if (ratio >= LINE_COUNT_MIN_RATIO) return [];

  return [{
    severity: 'error',
    message: `Line count too low: ${translatedLines} lines (${(ratio * 100).toFixed(1)}% of original ${originalLines} lines)`
  }];
}

function checkLineCountTooHigh(
  ratio: number,
  originalLines: number,
  translatedLines: number
): ValidationIssue[] {
  if (ratio <= LINE_COUNT_MAX_RATIO) return [];

  return [{
    severity: 'error',
    message: `Line count too high: ${translatedLines} lines (${(ratio * 100).toFixed(1)}% of original ${originalLines} lines)`
  }];
}

Re-run Tests:

βœ“ validateLineCount (2 tests) 2ms

βœ… Refactor: Tests still pass, code is cleaner!


5. Advanced Testing Patterns

5.1 Testing Async Functions

// translator.ts
export async function translatePost(
  content: string
): Promise<string> {
  const response = await fetch('https://api.claude.ai/translate', {
    method: 'POST',
    body: JSON.stringify({ content })
  });
  return response.json();
}

// translator.test.ts
import { describe, it, expect, vi } from 'vitest';

describe('translatePost', () => {
  it('should translate content via API', async () => {
    // βœ… Use async/await
    const result = await translatePost('μ•ˆλ…•ν•˜μ„Έμš”');

    expect(result).toContain('Hello');
  });
});

5.2 Mocking

// translator.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';

// βœ… Mock fetch
global.fetch = vi.fn();

describe('translatePost', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('should call API with correct params', async () => {
    // Setup mock
    (fetch as any).mockResolvedValue({
      json: async () => 'Translated text'
    });

    await translatePost('Original text');

    // Verify API call
    expect(fetch).toHaveBeenCalledWith(
      'https://api.claude.ai/translate',
      expect.objectContaining({
        method: 'POST',
        body: JSON.stringify({ content: 'Original text' })
      })
    );
  });

  it('should handle API errors', async () => {
    // Mock API error
    (fetch as any).mockRejectedValue(new Error('Network error'));

    await expect(translatePost('text')).rejects.toThrow('Network error');
  });
});

5.3 Testing Exception Handling

describe('validateTranslation', () => {
  it('should throw error for invalid input', () => {
    expect(() => {
      validateTranslation(null as any, 'valid');
    }).toThrow('Original content is required');
  });

  it('should return error for empty content', async () => {
    const result = await validateTranslation('', '');

    expect(result.isValid).toBe(false);
    expect(result.issues).toContainEqual({
      severity: 'error',
      message: 'Content is empty'
    });
  });
});

6. Coverage Measurement and Quality Management

6.1 Coverage Setup

# Install @vitest/coverage-v8
pnpm add -D @vitest/coverage-v8

# Run coverage
pnpm test --coverage

Result:

 % Coverage report from v8
-------------------|---------|----------|---------|---------|
File               | % Stmts | % Branch | % Funcs | % Lines |
-------------------|---------|----------|---------|---------|
All files          |   94.23 |    89.47 |   92.85 |   94.23 |
 validation.ts     |     100 |      100 |     100 |     100 |
 translator.ts     |   88.88 |    83.33 |   85.71 |   88.88 |
 markdown.ts       |   95.45 |    91.66 |   93.75 |   95.45 |
-------------------|---------|----------|---------|---------|

6.2 Coverage Goals

Recommended Thresholds:

  • Statements: β‰₯80%
  • Branches: β‰₯75%
  • Functions: β‰₯80%
  • Lines: β‰₯80%

Project Actual Achievement:

  • βœ… Statements: 94.23%
  • βœ… Branches: 89.47%
  • βœ… Functions: 92.85%
  • βœ… Lines: 94.23%

6.3 Finding Low Coverage Code

# Generate HTML report
pnpm test --coverage --reporter=html

# Open coverage/index.html
open coverage/index.html

Lines highlighted in red = untested code


7. Practical Tips and Pitfalls

Tip 1: Clear Test Names

// ❌ Bad examples
it('works', () => { ... });
it('test 1', () => { ... });

// βœ… Good examples
it('should return false for empty content', () => { ... });
it('should throw error when API key is missing', () => { ... });

Tip 2: Test Isolation (beforeEach)

describe('WordPressClient', () => {
  let client: WordPressClient;

  // βœ… Create new instance before each test
  beforeEach(() => {
    client = new WordPressClient({
      url: 'https://test.com',
      username: 'test',
      password: 'test'
    });
  });

  it('should create post', async () => {
    // client is in clean state
  });

  it('should update post', async () => {
    // No impact from previous test
  });
});

Tip 3: One Assertion per Test

// ❌ Bad: Multiple assertions
it('should do everything', () => {
  expect(result.isValid).toBe(true);
  expect(result.issues).toHaveLength(0);
  expect(result.metrics.lineCountDiff).toBe(0);
  expect(result.metrics.codeBlocks).toBe(3);
  // ... 10 more
});

// βœ… Good: Separate tests
it('should be valid', () => {
  expect(result.isValid).toBe(true);
});

it('should have no issues', () => {
  expect(result.issues).toHaveLength(0);
});

it('should preserve line count', () => {
  expect(result.metrics.lineCountDiff).toBe(0);
});

Pitfall 1: Test Order Dependencies

// ❌ Bad example
let sharedState = 0;

it('test 1', () => {
  sharedState = 10;
});

it('test 2', () => {
  // Assumes test 1 runs first (dangerous!)
  expect(sharedState).toBe(10);
});

Solution: Each test should be independent (use beforeEach)

Pitfall 2: Too Many Mocks

// ❌ Bad: Mock everything
vi.mock('./file1');
vi.mock('./file2');
vi.mock('./file3');
// ... 10 more

// βœ… Good: Mock only what's necessary (external APIs, file system, etc.)
vi.mock('node:fs');
global.fetch = vi.fn();

8. Measuring Real-World Results

8.1 Measurable Outcomes

Metric Before TDD After TDD Improvement
Bug Detection 7 days later (user found) Immediate (test fails) 90% faster
Refactoring Time 2 hours (anxious) 30 min (confident) 75% reduction
Debugging Time Average 4 hours Average 30 min 87% reduction
Code Coverage 0% 94% +94%p

8.2 Real Cases

Case 1: Translation Validation Bug

Before TDD:
- Day 1: Translation feature implementation complete
- Day 5: User "Code blocks disappeared"
- Day 6: Start finding root cause
- Day 7: Fix complete (total 3 days spent)

After TDD:
- Day 1: Write test β†’ Verify code block preservation
- Day 1: Implementation β†’ Test passes
- 0 bugs (prevented by tests)

Case 2: Refactoring Confidence

Before TDD:
- "What will break if I modify this function?" 😰
- Manual testing of entire app after changes (2 hours)

After TDD:
- "Run pnpm test" 😎
- 75 tests pass (30 seconds)
- Deploy with confidence

9. Conclusion

Key Takeaways

TDD Value:

  • βœ… Bug detection 90% faster (user discovery β†’ immediate test failure detection)
  • βœ… Refactoring confidence (tests are safety net)
  • βœ… Documentation effect (tests = executable examples)
  • βœ… Design improvement (testable code = good design)

vitest Advantages:

  • ⚑ 10x faster than Jest
  • 🟒 Perfect TypeScript support
  • πŸ“¦ Native ESM
  • πŸ”§ Simple setup

You Can Start Too

Minimal Start (10 minutes):

# 1. Install
pnpm add -D vitest

# 2. Write first test
echo 'import { describe, it, expect } from "vitest";
describe("My Function", () => {
  it("should work", () => {
    expect(1 + 1).toBe(2);
  });
});' > src/example.test.ts

# 3. Run
pnpm vitest

# Done! You're now a TDD developer πŸŽ‰

Next Steps

If you’ve mastered this pattern, check out these topics:

  • Day 3 Preview: Practical Guide to API Error Handling (Timeout, Retry, Fallback)
  • Related Topic: TypeScript Error Handling Best Practices

Final Thoughts

“Testing is not a waste of time, it’s an investment of time.”

It’s slow at first, but long-term you gain 10x faster development speed.

Developing without tests is like driving without a seatbelt.

Start safe and confident development with vitest + TDD.


Questions or feedback? Leave a comment!

Real Project Code: GitHub Repository – 75 tests, 94% coverage