Skip to main content

πŸ§ͺ Lesson 9.1: Testing Fundamentals

Master the foundations of testing React 19 applications with TypeScript. Learn why testing matters, understand the different kinds of tests, and write your first unit tests with Vitest β€” the fast, Vite-native runner that speaks TypeScript out of the box.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain why testing is essential for modern web applications
  • Identify and describe different types of tests (unit, integration, E2E)
  • Understand the testing pyramid (and the testing trophy) and how to balance your test suite
  • Set up Vitest for testing TypeScript code β€” and recognise the near-identical Jest API
  • Write your first unit tests using the AAA (Arrange-Act-Assert) pattern with describe/it/expect
  • Apply proper TypeScript typing to test functions and expectations

Estimated Time: 60-75 minutes

Project: Write comprehensive unit tests for utility functions

In This Lesson

πŸ“– Introduction to Testing

Welcome to Module 9! You've built amazing React applications with sophisticated features like forms, routing, state management, and data fetching. But there's a crucial skill that separates good developers from great ones: testing.

Testing isn't just about finding bugs (though it does that too). It's about building confidence in your code. It's about creating a safety net that allows you to refactor fearlessly, add new features quickly, and sleep well at night knowing your application works as expected.

πŸ“– What is Testing?

Testing is the process of running your code with specific inputs and verifying that it produces the expected outputs and behaviours. In software development, automated tests are little programs that check whether your application code still works correctly β€” and, crucially, they re-run in seconds every time you change something.

Think of testing like this: imagine you're building a bridge. You wouldn't just build it and hope it holds up. You'd test the materials, test the design with scale models, test the construction at each stage, and finally load-test the complete bridge before opening it to traffic. Software testing works the same way β€” we verify our code at multiple levels so that we're not just hoping it works. And unlike manually clicking through your app after every change, an automated test suite does that verification for you, tirelessly, on every commit.

πŸ’‘ The Testing Mindset

Testing changes how you write code. When you know you'll need to test your functions, you naturally write them to be more modular, focused, and easier to reason about. This makes your entire codebase betterβ€”even if you never ran a single test!

πŸ€” Why Testing Matters

Let's be honest: writing tests takes time. So why should you invest that time instead of just building more features? Here are compelling reasons why testing is essential:

πŸ›‘οΈ Confidence and Safety

Tests act as a safety net. When you modify code, tests immediately tell you if you broke something. This confidence allows you to:

  • Refactor boldly: Improve your code structure without fear
  • Add features quickly: Know that new code doesn't break existing functionality
  • Upgrade dependencies: Update libraries with confidence that everything still works

πŸ“ Documentation

Good tests serve as living documentation. They show other developers (and future you) exactly how your code is supposed to work. Instead of reading through implementation details, developers can look at tests to understand:

  • What inputs a function expects
  • What outputs it produces
  • How edge cases are handled
  • What errors might be thrown

βœ… Real-World Example

At a major tech company, a developer needed to refactor a critical payment processing function. Without tests, this would have been terrifyingβ€”one mistake could mean charging customers incorrectly. But because the function had comprehensive tests, the refactoring took just a few hours, and the tests confirmed that all payment scenarios still worked perfectly.

πŸ› Catch Bugs Early

Finding bugs during development is exponentially cheaper than finding them in production. Consider the cost of a bug at different stages:

Stage Time to Fix Impact Cost
During development (caught by tests) Minutes Developer only Very Low
During code review Hours Multiple developers Low
During QA testing Hours to days Development team blocked Medium
In production Days to weeks Users affected, reputation damage Very High

πŸš€ Better Design

When you write code with testing in mind (or better yet, write tests first), you naturally create better designs:

  • Smaller functions: Easier to test, easier to understand
  • Clear dependencies: Functions that are easy to test have clear inputs and outputs
  • Loose coupling: Testable code isn't tangled up with other systems
  • Single responsibility: Each function does one thing well

⚠️ Common Misconception

Myth: "Testing slows down development."

Reality: Tests slow down initial feature development by about 15-30%, but they speed up overall development by preventing bugs, enabling faster refactoring, and reducing debugging time. Over the lifetime of a project, tests save tremendous amounts of time.

πŸ‘₯ Team Collaboration

Tests make it easier for teams to work together:

  • Onboarding: New team members can understand how code works by reading tests
  • Code reviews: Reviewers can verify behavior without manually testing
  • Parallel development: Multiple developers can work on the same codebase without stepping on each other's toes
  • Trust: Team members trust that changes won't break their work
πŸ’¬ Industry Perspective: "The best time to start writing tests is at the beginning of a project. The second-best time is right now." – Every experienced developer who learned this lesson the hard way

🎯 Types of Tests

Not all tests are created equal. Different types of tests serve different purposes and test different aspects of your application. Understanding these differences helps you choose the right tool for the job.

1. Unit Tests πŸ”¬

Unit tests are the foundation of your test suite. They test individual units of code in isolationβ€”typically a single function or method.

πŸ“– Unit Test

Unit Test: A test that verifies a small, isolated piece of code (a "unit") works correctly. Unit tests focus on a single function, method, or component in complete isolation from the rest of the application.

Characteristics of Unit Tests:

  • Fast: Run in milliseconds
  • Isolated: No dependencies on databases, APIs, or file systems
  • Focused: Test one thing at a time
  • Deterministic: Always produce the same result given the same input
  • Numerous: You'll have hundreds or thousands of these

Example scenarios for unit tests:

  • Testing a function that formats currency: formatCurrency(1234.56) should return "$1,234.56"
  • Testing a validation function: isValidEmail("test@example.com") should return true
  • Testing a calculation: calculateTotal([10, 20, 30]) should return 60
  • Testing a React component that displays a user's name

βœ… When to Use Unit Tests

Use unit tests for business logic, utility functions, data transformations, validation, calculations, and pure components. These are your first line of defense and should make up 70-80% of your test suite.

2. Integration Tests πŸ”—

Integration tests verify that multiple units work together correctly. They test the interactions between different parts of your application.

πŸ“– Integration Test

Integration Test: A test that verifies multiple components or modules work together correctly. Integration tests focus on the interfaces between units and ensure they cooperate as expected. In React, a test that renders a form, types into it, and checks that submitting calls the right handler is a classic integration test.

Characteristics of Integration Tests:

  • Moderate speed: Slower than unit tests, but still relatively fast
  • Some real dependencies: May use real databases or APIs (or realistic mocks)
  • Broader scope: Test multiple components working together
  • More realistic: Closer to how users actually interact with your app
  • Moderate quantity: You'll have dozens to hundreds of these

Example scenarios for integration tests:

  • Testing a form that validates input and submits to an API
  • Testing a shopping cart that updates quantities and calculates totals
  • Testing a search feature that filters results and updates the UI
  • Testing navigation between pages in your app
  • Testing that multiple React components work together to display a dashboard

πŸ’‘ The Key Difference

Unit tests ask: "Does this function work correctly by itself?"
Integration tests ask: "Do these components work correctly together?"

3. End-to-End (E2E) Tests 🎭

End-to-End tests simulate real user scenarios from start to finish. They test your entire application stack as a user would experience it.

πŸ“– End-to-End Test

E2E Test: A test that simulates a complete user workflow through your application from beginning to end. E2E tests interact with your app just like a real user would, clicking buttons, filling forms, and verifying results β€” usually driving a real browser via a tool like Playwright or Cypress.

Characteristics of E2E Tests:

  • Slow: Can take seconds or minutes to run
  • Full stack: Test everything togetherβ€”frontend, backend, database
  • User-centric: Test complete user workflows
  • Can be brittle: Small UI changes can break tests
  • Few but important: You'll have a handful to a few dozen of these

Example scenarios for E2E tests:

  • Testing a complete user registration flow: sign up β†’ verify email β†’ log in β†’ see dashboard
  • Testing an e-commerce purchase: browse products β†’ add to cart β†’ checkout β†’ payment β†’ confirmation
  • Testing a blog workflow: log in β†’ create post β†’ publish β†’ verify it appears on homepage
  • Testing critical user journeys that must always work

πŸ’‘ Tools for Each Level

Each layer has its own go-to tools in a modern React + TypeScript project:

This lesson focuses on the base of the pyramid β€” unit tests with Vitest β€” because that's where you'll spend most of your testing time.

⚠️ E2E Test Considerations

E2E tests are powerful but expensive to write and maintain. Use them sparingly for critical user paths. Don't try to test every scenario with E2E testsβ€”that's what unit and integration tests are for!

Comparison Table

Aspect Unit Tests Integration Tests E2E Tests
Scope Single function/component Multiple components together Entire application
Speed Very fast (milliseconds) Moderate (seconds) Slow (seconds to minutes)
Cost to Write Low Medium High
Maintenance Easy Moderate Difficult
Confidence Low to Medium Medium to High Very High
Quantity Hundreds to thousands Dozens to hundreds Handful to few dozen
Failures Pinpoint exact issue Narrow down to area Know something is broken

πŸ”Ί The Testing Pyramid

The testing pyramid is a foundational concept in test strategy. It visualizes how to balance different types of tests to create an effective, efficient, and maintainable test suite.

graph TD E2E["E2E Tests
🎭
10-20%
Complete user workflows"] INT["Integration Tests
πŸ”—
20-30%
Components working together"] UNIT["Unit Tests
πŸ”¬
50-70%
Individual functions"] E2E --> INT INT --> UNIT style E2E fill:#ff6b6b,stroke:#c92a2a,stroke-width:2px,color:#fff style INT fill:#4dabf7,stroke:#1971c2,stroke-width:2px,color:#fff style UNIT fill:#51cf66,stroke:#2f9e44,stroke-width:2px,color:#fff

Understanding the Pyramid

πŸ”¬ Base: Unit Tests (50-70% of your tests)

Unit tests form the foundation of your testing pyramid. They should be:

  • Numerous: Test every important function and edge case
  • Fast: Your entire unit test suite should run in seconds
  • Comprehensive: Cover all business logic thoroughly
  • Easy to write: If they're hard to write, your code might need refactoring

βœ… Why So Many Unit Tests?

Unit tests are cheap to write and maintain, run instantly, and pinpoint exactly what's broken. They give you rapid feedback during development and catch bugs before they spread to other parts of your application.

πŸ”— Middle: Integration Tests (20-30% of your tests)

Integration tests verify that your components work together correctly. They should:

  • Test boundaries: Focus on how units interact at their interfaces
  • Be realistic: Use real implementations when possible
  • Cover workflows: Test common user scenarios
  • Balance speed and realism: Fast enough to run frequently, realistic enough to catch real issues

🎭 Top: E2E Tests (10-20% of your tests)

E2E tests validate critical user journeys. They should:

  • Test happy paths: Focus on the most important user workflows
  • Simulate real users: Click, type, navigate like actual users would
  • Be selective: Only test the most critical features
  • Be resilient: Written to survive minor UI changes

Why the Pyramid Shape?

The pyramid shape exists for good reasons:

πŸ’‘ The Math Behind the Pyramid

Speed: 1,000 unit tests might run in 5 seconds. 100 integration tests might take 30 seconds. 10 E2E tests could take 5 minutes. The pyramid keeps your total test runtime manageable.

Maintenance: Unit tests rarely break when you change unrelated code. E2E tests can break from any UI change. More unit tests = less maintenance burden.

Debugging: When a unit test fails, you know exactly which function is broken. When an E2E test fails, you might need to debug the entire application to find the issue.

πŸ’‘ A Modern Twist: The Testing Trophy

In the React community you'll often hear about the testing trophy (popularised by Kent C. Dodds) rather than the pyramid. It keeps a thin base of static checks (TypeScript and ESLint catch a whole class of bugs before a single test runs) and then makes integration tests the fattest layer β€” the reasoning being that testing components the way a user actually uses them gives you the most confidence per line of test code.

The pyramid and the trophy aren't enemies. Both say the same core thing: lean on many fast, cheap tests and use slow end-to-end tests sparingly. With TypeScript, you already get the trophy's static-analysis base for free.

❌ Anti-Pattern: The Ice Cream Cone

Some teams accidentally create an inverted pyramidβ€”lots of E2E tests, few unit tests. This is called the "ice cream cone" anti-pattern, and it's problematic:

graph TD UNIT2["Unit Tests
πŸ”¬
10-20%
Not enough!"] INT2["Integration Tests
πŸ”—
20-30%"] E2E2["E2E Tests
🎭
50-70%
Too many!"] UNIT2 --> INT2 INT2 --> E2E2 style UNIT2 fill:#51cf66,stroke:#2f9e44,stroke-width:2px,color:#fff style INT2 fill:#4dabf7,stroke:#1971c2,stroke-width:2px,color:#fff style E2E2 fill:#ff6b6b,stroke:#c92a2a,stroke-width:2px,color:#fff

⚠️ Problems with the Ice Cream Cone

  • Slow feedback: Tests take forever to run
  • Difficult debugging: When tests fail, it's hard to find the root cause
  • High maintenance: UI changes break many tests
  • Flaky tests: E2E tests are prone to random failures
  • Developer frustration: Teams stop trusting or running tests

Practical Application

Let's say you're building a task management app. Here's how you might apply the testing pyramid:

πŸ“‹ Example: Task Manager Testing Strategy

Unit Tests (70%):

  • Date formatting functions
  • Task validation logic
  • Priority calculation
  • Individual React components
  • Custom hooks

Integration Tests (25%):

  • Adding a task updates the list
  • Filtering works with the task list
  • Form validation integrates with submit
  • State management works across components

E2E Tests (5%):

  • Complete flow: Create account β†’ Add task β†’ Mark complete β†’ View in completed list
  • Critical path: Login β†’ Create project β†’ Add tasks β†’ Share with team
πŸ’‘ Pro Tip: Start with the pyramid in mind, but don't be dogmatic about the exact percentages. The right balance depends on your application. A simple utility library might be 95% unit tests, while a complex UI-heavy app might have more integration tests.

βš™οΈ Setting Up Vitest

To run tests, you need a test runner: a tool that finds your test files, executes them, reports the results, and gives you the assertion helpers to check your expectations. For a modern React + TypeScript project built with Vite, the natural choice is Vitest.

πŸ“– What is Vitest?

Vitest is a fast, Vite-native testing framework. It reuses your project's existing Vite config, so it understands TypeScript, JSX, ESM, and path aliases with zero extra setup. It bundles a test runner, an assertion library, mocking (vi), and coverage β€” everything you need in one package.

Why Vitest?

Vitest has become the default for Vite-based React apps for several compelling reasons:

  • TypeScript & ESM out of the box: no ts-jest or Babel plumbing to configure
  • Reuses your Vite config: aliases, plugins, and env handling "just work"
  • Blazing fast: runs tests in parallel and only re-runs what changed in watch mode
  • Jest-compatible API: describe, it/test, expect, and matchers behave the same
  • Built-in mocking & coverage: use vi for mocks and --coverage for reports
  • Great error messages & UI: clear diffs, plus an optional browser dashboard (@vitest/ui)

πŸ’‘ Vitest vs Jest

Jest (created at Facebook/Meta) is the classic, battle-tested runner and still powers countless codebases β€” including Create React App projects. The good news: Vitest was deliberately built with a near-identical API. Almost everything in this lesson β€” describe, it, expect, matchers, the AAA pattern β€” reads the same in both. The main differences are the config file and that Vitest's mock helper is vi where Jest's is jest. Learn one and you effectively know both.

Installing Vitest with TypeScript

In a Vite + React + TypeScript project, add Vitest and the React Testing Library tools as dev dependencies:

# Vitest test runner + jsdom to simulate a browser environment
npm install --save-dev vitest jsdom

# React Testing Library for rendering components in tests
npm install --save-dev @testing-library/react @testing-library/jest-dom
npm install --save-dev @testing-library/user-event

# Optional: the browser-based Vitest dashboard
npm install --save-dev @vitest/ui

Configuring Vitest

Add a test block to your vite.config.ts (Vitest reads the same file, so there's usually no separate config to maintain):

// vite.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    // Use jsdom so component tests have a DOM to render into
    environment: 'jsdom',
    // Expose describe / it / expect without importing them everywhere
    globals: true,
    // Runs once before the test suite (custom matchers, cleanup, etc.)
    setupFiles: './src/test/setup.ts',
    // Collect coverage from your source files
    coverage: {
      include: ['src/**/*.{ts,tsx}'],
      exclude: ['src/**/*.d.ts', 'src/main.tsx'],
    },
  },
});

Setup File

Create src/test/setup.ts to wire up the extra DOM matchers (like toBeInTheDocument()) and reset the DOM between tests:

// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';

// Unmount rendered components after every test to keep them isolated
afterEach(() => {
  cleanup();
});

Update package.json

Add test scripts to your package.json:

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

πŸ’‘ Watch mode is the default

Running npm test starts Vitest in watch mode: it stays open and re-runs only the tests affected by your latest change. Use vitest run (a single, one-shot pass) in CI, where you want the process to exit with a pass/fail status.

βœ… Quick Setup Verification

After setup, run npm test to verify everything works. You should see a message like "No test files found" if you haven't written any tests yet β€” that's perfect!

File Naming Conventions

Vitest (and Jest) automatically discover test files using these naming patterns:

  • *.test.ts or *.test.tsx β€” right next to your source files
  • *.spec.ts or *.spec.tsx β€” alternative convention
  • __tests__/*.ts or __tests__/*.tsx β€” inside a __tests__ folder

πŸ’‘ Recommended Structure

For React components, place test files next to the component:

src/
  components/
    Button/
      Button.tsx
      Button.test.tsx
      Button.css

For utility functions, you can use either approach (co-located or __tests__ folder).

✍️ Writing Your First Test

Let's write our very first test! We'll start simple with a utility function, then build up to more complex scenarios.

A Simple Function to Test

Create a file called utils.ts:


// utils.ts
export function add(a: number, b: number): number {
  return a + b;
}

export function subtract(a: number, b: number): number {
  return a - b;
}

export function multiply(a: number, b: number): number {
  return a * b;
}
                

Your First Test File

Now create utils.test.ts in the same directory:

// utils.test.ts
import { describe, it, expect } from 'vitest';
import { add, subtract, multiply } from './utils';

describe('math utils', () => {
  it('adds two numbers correctly', () => {
    const result = add(2, 3);
    expect(result).toBe(5);
  });

  it('subtracts two numbers correctly', () => {
    const result = subtract(10, 4);
    expect(result).toBe(6);
  });

  it('multiplies two numbers correctly', () => {
    const result = multiply(3, 4);
    expect(result).toBe(12);
  });
});

πŸ’‘ To import or not to import?

Because we set globals: true in the config, describe, it, and expect are available without importing them (exactly like Jest). Many teams still prefer the explicit import { describe, it, expect } from 'vitest' shown above β€” it makes the source of these functions obvious and plays nicely with editors and linters. Both styles are correct; pick one and be consistent.

Understanding Test Syntax

Let's break down what's happening in these tests:

πŸ“– Test Anatomy

it('description of what you are testing', () => {
  // Test code goes here
  expect(actualValue).toBe(expectedValue);
});
  • it(): defines a test (test() is an exact alias β€” pick whichever reads better; it('...') tends to read like a sentence)
  • Description string: explains what the test does in plain English
  • Callback function: contains the actual test code
  • expect(): creates an assertion about a value
  • .toBe(): a "matcher" that checks whether values are equal

Running Your Tests

Run your tests with:

npm test

You should see output like this:

 βœ“ src/utils.test.ts (3 tests) 4ms
   βœ“ math utils > adds two numbers correctly
   βœ“ math utils > subtracts two numbers correctly
   βœ“ math utils > multiplies two numbers correctly

 Test Files  1 passed (1)
      Tests  3 passed (3)
   Start at  10:24:07
   Duration  312ms

πŸŽ‰ Congratulations!

You just wrote and ran your first automated tests! That green checkmark feeling is addictiveβ€”you'll want to see it all the time.

Common Matchers

Vitest ships a rich set of matchers for different kinds of assertions β€” and because the API mirrors Jest, every matcher below works identically in both runners:

Matcher Use Case Example
.toBe() Exact equality (===) expect(2 + 2).toBe(4)
.toEqual() Deep equality for objects/arrays expect(obj).toEqual({a: 1})
.toBeTruthy() Value is truthy expect("hello").toBeTruthy()
.toBeFalsy() Value is falsy expect(0).toBeFalsy()
.toBeNull() Value is null expect(value).toBeNull()
.toBeUndefined() Value is undefined expect(value).toBeUndefined()
.toContain() Array/string contains item expect([1,2,3]).toContain(2)
.toHaveLength() Array/string length expect(arr).toHaveLength(3)
.toThrow() Function throws error expect(fn).toThrow()
.toBeGreaterThan() Numeric comparison expect(10).toBeGreaterThan(5)

Negating Matchers

You can negate any matcher with .not:


test('number is not zero', () => {
  expect(5).not.toBe(0);
});

test('array does not contain item', () => {
  expect([1, 2, 3]).not.toContain(4);
});
                

🎯 The AAA Pattern

The AAA (Arrange-Act-Assert) pattern is a widely-used structure for writing clear, maintainable tests. It helps organize your test code and makes tests easier to understand.

πŸ“– AAA Pattern

Arrange-Act-Assert (AAA) is a pattern for structuring tests:

  • Arrange: Set up the test data and conditions
  • Act: Execute the code being tested
  • Assert: Verify the results are correct
The Arrange-Act-Assert flow Three sequential stages: Arrange sets up data, Act runs the code, Assert checks the result. 1 Β· Arrange set up inputs 2 Β· Act run the code 3 Β· Assert check the result
Figure 1: Every well-structured test flows through the same three stages, in order.

Breaking Down the Pattern

1️⃣ Arrange

Set up everything needed for the test:

  • Create test data
  • Configure mocks
  • Set initial state
  • Prepare any dependencies

2️⃣ Act

Execute the specific behavior you're testing:

  • Call the function
  • Trigger the event
  • Perform the action

3️⃣ Assert

Verify the outcome matches expectations:

  • Check return values
  • Verify state changes
  • Confirm side effects

Example: Without AAA Pattern

Here's a test without clear structure:


// ❌ Hard to read and understand
test('calculates user discount', () => {
  expect(calculateDiscount({ isPremium: true, cartTotal: 100 })).toBe(90);
});
                

Example: With AAA Pattern

Now let's apply the AAA pattern:


// βœ… Clear and easy to understand
test('calculates 10% discount for premium users', () => {
  // Arrange - Set up test data
  const user = {
    isPremium: true,
    cartTotal: 100
  };
  
  // Act - Execute the function
  const discountedTotal = calculateDiscount(user);
  
  // Assert - Verify the result
  expect(discountedTotal).toBe(90);
});
                

βœ… Benefits of AAA Pattern

  • Clarity: Anyone can understand what the test does
  • Maintainability: Easy to modify and update
  • Debugging: Quick to identify which part failed
  • Consistency: All tests follow the same structure

More Complex Example

Let's see AAA with a more realistic scenario:


// Test a function that formats user data
interface User {
  firstName: string;
  lastName: string;
  email: string;
}

function formatUserDisplay(user: User): string {
  return `${user.firstName} ${user.lastName} (${user.email})`;
}

test('formats user display with all information', () => {
  // Arrange - Create test user
  const testUser: User = {
    firstName: 'Alice',
    lastName: 'Johnson',
    email: 'alice@example.com'
  };
  
  // Act - Format the user
  const displayString = formatUserDisplay(testUser);
  
  // Assert - Verify correct format
  expect(displayString).toBe('Alice Johnson (alice@example.com)');
});
                

AAA with Multiple Assertions

Sometimes you need multiple assertions. That's fine, as long as they're all testing the same behavior:


interface ShoppingCart {
  items: Array<{ name: string; price: number }>;
  total: number;
  itemCount: number;
}

function addItemToCart(
  cart: ShoppingCart, 
  item: { name: string; price: number }
): ShoppingCart {
  return {
    items: [...cart.items, item],
    total: cart.total + item.price,
    itemCount: cart.itemCount + 1
  };
}

test('adding item updates cart correctly', () => {
  // Arrange
  const initialCart: ShoppingCart = {
    items: [],
    total: 0,
    itemCount: 0
  };
  
  const newItem = {
    name: 'Widget',
    price: 29.99
  };
  
  // Act
  const updatedCart = addItemToCart(initialCart, newItem);
  
  // Assert - Multiple related assertions are OK
  expect(updatedCart.items).toHaveLength(1);
  expect(updatedCart.items[0]).toEqual(newItem);
  expect(updatedCart.total).toBe(29.99);
  expect(updatedCart.itemCount).toBe(1);
});
                

πŸ’‘ Pro Tip: Comments Are Optional

Once you're comfortable with AAA, you don't need to write the comments. The pattern should be evident from the code structure itself. Use blank lines to separate the three sections visually.

Using describe() for Organization

For related tests, use describe() blocks to group them:


describe('ShoppingCart', () => {
  describe('addItemToCart', () => {
    test('adds item to empty cart', () => {
      // Arrange
      const cart: ShoppingCart = { items: [], total: 0, itemCount: 0 };
      const item = { name: 'Widget', price: 10 };
      
      // Act
      const result = addItemToCart(cart, item);
      
      // Assert
      expect(result.itemCount).toBe(1);
    });
    
    test('adds item to cart with existing items', () => {
      // Arrange
      const cart: ShoppingCart = { 
        items: [{ name: 'Existing', price: 5 }], 
        total: 5, 
        itemCount: 1 
      };
      const item = { name: 'Widget', price: 10 };
      
      // Act
      const result = addItemToCart(cart, item);
      
      // Assert
      expect(result.itemCount).toBe(2);
      expect(result.total).toBe(15);
    });
  });
});
                

⚠️ Watch Out: Too Many Assertions

If you find yourself writing many unrelated assertions in a single test, you might be testing too much at once. Consider splitting into multiple tests, each focused on one behavior.

πŸ”§ Testing Utility Functions

Utility functions are the perfect starting point for testing because they're typically pure functionsβ€”same input always produces the same output. Let's test some real-world utilities.

Example 1: Email Validation

First, let's create the utility function:


// validators.ts
export function isValidEmail(email: string): boolean {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailRegex.test(email);
}
                

Now let's write comprehensive tests:


// validators.test.ts
import { isValidEmail } from './validators';

describe('isValidEmail', () => {
  test('returns true for valid email', () => {
    // Arrange
    const email = 'user@example.com';
    
    // Act
    const result = isValidEmail(email);
    
    // Assert
    expect(result).toBe(true);
  });
  
  test('returns false for email without @', () => {
    const email = 'userexample.com';
    const result = isValidEmail(email);
    expect(result).toBe(false);
  });
  
  test('returns false for email without domain', () => {
    const email = 'user@';
    const result = isValidEmail(email);
    expect(result).toBe(false);
  });
  
  test('returns false for email without local part', () => {
    const email = '@example.com';
    const result = isValidEmail(email);
    expect(result).toBe(false);
  });
  
  test('returns false for email with spaces', () => {
    const email = 'user @example.com';
    const result = isValidEmail(email);
    expect(result).toBe(false);
  });
  
  test('returns true for email with subdomain', () => {
    const email = 'user@mail.example.com';
    const result = isValidEmail(email);
    expect(result).toBe(true);
  });
});
                

βœ… Testing Edge Cases

Notice how we test both valid inputs and various invalid inputs. Good tests cover:

  • Happy path: Valid, expected inputs
  • Edge cases: Boundary conditions
  • Invalid inputs: What happens with bad data

Example 2: Currency Formatting


// formatters.ts
export function formatCurrency(
  amount: number, 
  currency: string = 'USD'
): string {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: currency
  }).format(amount);
}
                

// formatters.test.ts
import { formatCurrency } from './formatters';

describe('formatCurrency', () => {
  test('formats positive amount in USD', () => {
    const result = formatCurrency(1234.56);
    expect(result).toBe('$1,234.56');
  });
  
  test('formats zero correctly', () => {
    const result = formatCurrency(0);
    expect(result).toBe('$0.00');
  });
  
  test('formats negative amount', () => {
    const result = formatCurrency(-50.25);
    expect(result).toBe('-$50.25');
  });
  
  test('rounds to two decimal places', () => {
    const result = formatCurrency(10.999);
    expect(result).toBe('$11.00');
  });
  
  test('formats large numbers with commas', () => {
    const result = formatCurrency(1000000);
    expect(result).toBe('$1,000,000.00');
  });
  
  test('accepts different currency codes', () => {
    const result = formatCurrency(100, 'EUR');
    expect(result).toBe('€100.00');
  });
});
                

Example 3: Array Utilities


// arrayUtils.ts
export function removeDuplicates<T>(array: T[]): T[] {
  return [...new Set(array)];
}

export function chunk<T>(array: T[], size: number): T[][] {
  const chunks: T[][] = [];
  for (let i = 0; i < array.length; i += size) {
    chunks.push(array.slice(i, i + size));
  }
  return chunks;
}
                

// arrayUtils.test.ts
import { removeDuplicates, chunk } from './arrayUtils';

describe('removeDuplicates', () => {
  test('removes duplicate numbers', () => {
    // Arrange
    const numbers = [1, 2, 2, 3, 3, 3, 4];
    
    // Act
    const result = removeDuplicates(numbers);
    
    // Assert
    expect(result).toEqual([1, 2, 3, 4]);
  });
  
  test('removes duplicate strings', () => {
    const strings = ['a', 'b', 'a', 'c', 'b'];
    const result = removeDuplicates(strings);
    expect(result).toEqual(['a', 'b', 'c']);
  });
  
  test('returns empty array for empty input', () => {
    const result = removeDuplicates([]);
    expect(result).toEqual([]);
  });
  
  test('returns same array if no duplicates', () => {
    const numbers = [1, 2, 3, 4];
    const result = removeDuplicates(numbers);
    expect(result).toEqual([1, 2, 3, 4]);
  });
});

describe('chunk', () => {
  test('splits array into chunks of specified size', () => {
    const array = [1, 2, 3, 4, 5, 6];
    const result = chunk(array, 2);
    expect(result).toEqual([[1, 2], [3, 4], [5, 6]]);
  });
  
  test('handles last chunk with fewer elements', () => {
    const array = [1, 2, 3, 4, 5];
    const result = chunk(array, 2);
    expect(result).toEqual([[1, 2], [3, 4], [5]]);
  });
  
  test('returns single chunk if size larger than array', () => {
    const array = [1, 2, 3];
    const result = chunk(array, 5);
    expect(result).toEqual([[1, 2, 3]]);
  });
  
  test('returns empty array for empty input', () => {
    const result = chunk([], 2);
    expect(result).toEqual([]);
  });
});
                

TypeScript-Specific Testing

When testing TypeScript code, make sure your tests are properly typed:


// Type-safe test
test('function returns correct type', () => {
  interface User {
    id: number;
    name: string;
  }
  
  function getUser(id: number): User {
    return { id, name: 'Test User' };
  }
  
  // Arrange
  const userId = 1;
  
  // Act
  const user: User = getUser(userId); // TypeScript ensures return type
  
  // Assert
  expect(user).toHaveProperty('id');
  expect(user).toHaveProperty('name');
  expect(user.id).toBe(userId);
});
                

πŸ’‘ Test Coverage Goal

Aim for high coverage on utility functions since they're used throughout your application. A bug in a utility function can affect many features, so thorough testing here pays dividends.

πŸ‹οΈ Hands-on Exercises

Now it's time to practice! These exercises will help you solidify your understanding of testing fundamentals. Work through each one, and don't peek at the solutions until you've tried yourself.

πŸ‹οΈ Exercise 1: String Utilities

Objective: Write tests for string manipulation functions.

Instructions:

  1. Create a file called stringUtils.ts
  2. Implement the following functions:
    • capitalize(str: string): string - Capitalizes first letter
    • truncate(str: string, maxLength: number): string - Truncates string with "..."
    • isPalindrome(str: string): boolean - Checks if string is palindrome (ignore case/spaces)
  3. Create stringUtils.test.ts and write tests for each function
  4. Test edge cases: empty strings, special characters, very long strings

Starter Code:


// stringUtils.ts
export function capitalize(str: string): string {
  // TODO: Implement
  return '';
}

export function truncate(str: string, maxLength: number): string {
  // TODO: Implement
  return '';
}

export function isPalindrome(str: string): boolean {
  // TODO: Implement
  return false;
}
                    
πŸ’‘ Hint

For capitalize(), handle empty strings and already-capitalized strings.

For truncate(), only add "..." if the string is actually longer than maxLength.

For isPalindrome(), normalize the string (lowercase, remove spaces) before comparing.

βœ… Solution

// stringUtils.ts
export function capitalize(str: string): string {
  if (!str) return '';
  return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
}

export function truncate(str: string, maxLength: number): string {
  if (str.length <= maxLength) return str;
  return str.slice(0, maxLength) + '...';
}

export function isPalindrome(str: string): boolean {
  const normalized = str.toLowerCase().replace(/\s/g, '');
  return normalized === normalized.split('').reverse().join('');
}

// stringUtils.test.ts
import { capitalize, truncate, isPalindrome } from './stringUtils';

describe('capitalize', () => {
  test('capitalizes first letter of lowercase word', () => {
    expect(capitalize('hello')).toBe('Hello');
  });
  
  test('handles already capitalized word', () => {
    expect(capitalize('Hello')).toBe('Hello');
  });
  
  test('handles empty string', () => {
    expect(capitalize('')).toBe('');
  });
  
  test('lowercases rest of word', () => {
    expect(capitalize('hELLO')).toBe('Hello');
  });
});

describe('truncate', () => {
  test('truncates long string', () => {
    const result = truncate('This is a long string', 10);
    expect(result).toBe('This is a ...');
  });
  
  test('returns short string unchanged', () => {
    const result = truncate('Short', 10);
    expect(result).toBe('Short');
  });
  
  test('returns string of exact max length unchanged', () => {
    const result = truncate('Exactly10!', 10);
    expect(result).toBe('Exactly10!');
  });
});

describe('isPalindrome', () => {
  test('returns true for simple palindrome', () => {
    expect(isPalindrome('racecar')).toBe(true);
  });
  
  test('returns true ignoring case', () => {
    expect(isPalindrome('RaceCar')).toBe(true);
  });
  
  test('returns true ignoring spaces', () => {
    expect(isPalindrome('race car')).toBe(true);
  });
  
  test('returns false for non-palindrome', () => {
    expect(isPalindrome('hello')).toBe(false);
  });
  
  test('returns true for single character', () => {
    expect(isPalindrome('a')).toBe(true);
  });
});
                        

πŸ‹οΈ Exercise 2: Date Utilities

Objective: Test date manipulation functions with proper TypeScript typing.

Instructions:

  1. Create dateUtils.ts with these functions:
    • formatDate(date: Date): string - Format as "YYYY-MM-DD"
    • isWeekend(date: Date): boolean - Check if Saturday or Sunday
    • daysBetween(date1: Date, date2: Date): number - Calculate days between dates
  2. Write comprehensive tests in dateUtils.test.ts
  3. Use the AAA pattern for all tests
πŸ’‘ Hint

For formatDate(), use toISOString() and string manipulation, or create your own formatter.

For isWeekend(), remember getDay() returns 0 for Sunday and 6 for Saturday.

For daysBetween(), convert dates to milliseconds, find difference, and convert back to days.

βœ… Solution

// dateUtils.ts
export function formatDate(date: Date): string {
  const year = date.getFullYear();
  const month = String(date.getMonth() + 1).padStart(2, '0');
  const day = String(date.getDate()).padStart(2, '0');
  return `${year}-${month}-${day}`;
}

export function isWeekend(date: Date): boolean {
  const dayOfWeek = date.getDay();
  return dayOfWeek === 0 || dayOfWeek === 6;
}

export function daysBetween(date1: Date, date2: Date): number {
  const msPerDay = 24 * 60 * 60 * 1000;
  const diffMs = Math.abs(date2.getTime() - date1.getTime());
  return Math.floor(diffMs / msPerDay);
}

// dateUtils.test.ts
import { formatDate, isWeekend, daysBetween } from './dateUtils';

describe('formatDate', () => {
  test('formats date correctly', () => {
    // Arrange
    const date = new Date('2024-03-15');
    
    // Act
    const result = formatDate(date);
    
    // Assert
    expect(result).toBe('2024-03-15');
  });
  
  test('pads single-digit months and days', () => {
    const date = new Date('2024-01-05');
    const result = formatDate(date);
    expect(result).toBe('2024-01-05');
  });
});

describe('isWeekend', () => {
  test('returns true for Saturday', () => {
    // Arrange - March 16, 2024 is a Saturday
    const saturday = new Date('2024-03-16');
    
    // Act
    const result = isWeekend(saturday);
    
    // Assert
    expect(result).toBe(true);
  });
  
  test('returns true for Sunday', () => {
    const sunday = new Date('2024-03-17');
    const result = isWeekend(sunday);
    expect(result).toBe(true);
  });
  
  test('returns false for weekday', () => {
    const monday = new Date('2024-03-18');
    const result = isWeekend(monday);
    expect(result).toBe(false);
  });
});

describe('daysBetween', () => {
  test('calculates days between dates', () => {
    // Arrange
    const date1 = new Date('2024-01-01');
    const date2 = new Date('2024-01-11');
    
    // Act
    const result = daysBetween(date1, date2);
    
    // Assert
    expect(result).toBe(10);
  });
  
  test('handles dates in reverse order', () => {
    const date1 = new Date('2024-01-11');
    const date2 = new Date('2024-01-01');
    const result = daysBetween(date1, date2);
    expect(result).toBe(10);
  });
  
  test('returns zero for same date', () => {
    const date = new Date('2024-01-01');
    const result = daysBetween(date, date);
    expect(result).toBe(0);
  });
});
                        

πŸ‹οΈ Exercise 3: Shopping Cart Logic

Objective: Test more complex business logic with objects and arrays.

Instructions:

  1. Create cart.ts with these interfaces and functions:
    
    interface CartItem {
      id: string;
      name: string;
      price: number;
      quantity: number;
    }
    
    interface Cart {
      items: CartItem[];
      subtotal: number;
      tax: number;
      total: number;
    }
    
    // Implement these:
    function addItem(cart: Cart, item: CartItem): Cart
    function removeItem(cart: Cart, itemId: string): Cart
    function calculateTotals(cart: Cart, taxRate: number): Cart
    function getItemCount(cart: Cart): number
                                
  2. Write tests for each function
  3. Test edge cases: empty cart, removing non-existent items, zero prices
  4. Ensure functions return new objects (immutability)
πŸ’‘ Hint

Use spread operators to create new objects/arrays instead of mutating existing ones.

For calculateTotals(), remember: total = subtotal + tax where tax = subtotal * taxRate

For getItemCount(), sum up all quantities, not just array length.

βœ… Solution

// cart.ts
export interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

export interface Cart {
  items: CartItem[];
  subtotal: number;
  tax: number;
  total: number;
}

export function addItem(cart: Cart, item: CartItem): Cart {
  const newItems = [...cart.items, item];
  const subtotal = newItems.reduce((sum, i) => sum + (i.price * i.quantity), 0);
  
  return {
    ...cart,
    items: newItems,
    subtotal,
    total: subtotal + cart.tax
  };
}

export function removeItem(cart: Cart, itemId: string): Cart {
  const newItems = cart.items.filter(item => item.id !== itemId);
  const subtotal = newItems.reduce((sum, i) => sum + (i.price * i.quantity), 0);
  
  return {
    ...cart,
    items: newItems,
    subtotal,
    total: subtotal + cart.tax
  };
}

export function calculateTotals(cart: Cart, taxRate: number): Cart {
  const subtotal = cart.items.reduce((sum, i) => sum + (i.price * i.quantity), 0);
  const tax = subtotal * taxRate;
  
  return {
    ...cart,
    subtotal,
    tax,
    total: subtotal + tax
  };
}

export function getItemCount(cart: Cart): number {
  return cart.items.reduce((sum, item) => sum + item.quantity, 0);
}

// cart.test.ts
import { addItem, removeItem, calculateTotals, getItemCount, Cart, CartItem } from './cart';

describe('Shopping Cart', () => {
  describe('addItem', () => {
    test('adds item to empty cart', () => {
      // Arrange
      const emptyCart: Cart = { items: [], subtotal: 0, tax: 0, total: 0 };
      const item: CartItem = { id: '1', name: 'Widget', price: 10, quantity: 2 };
      
      // Act
      const result = addItem(emptyCart, item);
      
      // Assert
      expect(result.items).toHaveLength(1);
      expect(result.items[0]).toEqual(item);
      expect(result.subtotal).toBe(20);
    });
    
    test('adds item to cart with existing items', () => {
      const cart: Cart = {
        items: [{ id: '1', name: 'First', price: 10, quantity: 1 }],
        subtotal: 10,
        tax: 0,
        total: 10
      };
      const newItem: CartItem = { id: '2', name: 'Second', price: 15, quantity: 1 };
      
      const result = addItem(cart, newItem);
      
      expect(result.items).toHaveLength(2);
      expect(result.subtotal).toBe(25);
    });
    
    test('does not mutate original cart', () => {
      const cart: Cart = { items: [], subtotal: 0, tax: 0, total: 0 };
      const item: CartItem = { id: '1', name: 'Widget', price: 10, quantity: 1 };
      
      addItem(cart, item);
      
      expect(cart.items).toHaveLength(0); // Original unchanged
    });
  });
  
  describe('removeItem', () => {
    test('removes item from cart', () => {
      // Arrange
      const cart: Cart = {
        items: [
          { id: '1', name: 'Item1', price: 10, quantity: 1 },
          { id: '2', name: 'Item2', price: 20, quantity: 1 }
        ],
        subtotal: 30,
        tax: 0,
        total: 30
      };
      
      // Act
      const result = removeItem(cart, '1');
      
      // Assert
      expect(result.items).toHaveLength(1);
      expect(result.items[0].id).toBe('2');
      expect(result.subtotal).toBe(20);
    });
    
    test('handles removing non-existent item', () => {
      const cart: Cart = {
        items: [{ id: '1', name: 'Item', price: 10, quantity: 1 }],
        subtotal: 10,
        tax: 0,
        total: 10
      };
      
      const result = removeItem(cart, '999');
      
      expect(result.items).toHaveLength(1);
      expect(result.subtotal).toBe(10);
    });
  });
  
  describe('calculateTotals', () => {
    test('calculates subtotal, tax, and total', () => {
      // Arrange
      const cart: Cart = {
        items: [
          { id: '1', name: 'Item', price: 100, quantity: 2 }
        ],
        subtotal: 0,
        tax: 0,
        total: 0
      };
      const taxRate = 0.08; // 8% tax
      
      // Act
      const result = calculateTotals(cart, taxRate);
      
      // Assert
      expect(result.subtotal).toBe(200);
      expect(result.tax).toBe(16);
      expect(result.total).toBe(216);
    });
    
    test('handles zero tax rate', () => {
      const cart: Cart = {
        items: [{ id: '1', name: 'Item', price: 50, quantity: 1 }],
        subtotal: 0,
        tax: 0,
        total: 0
      };
      
      const result = calculateTotals(cart, 0);
      
      expect(result.subtotal).toBe(50);
      expect(result.tax).toBe(0);
      expect(result.total).toBe(50);
    });
  });
  
  describe('getItemCount', () => {
    test('returns total quantity of items', () => {
      // Arrange
      const cart: Cart = {
        items: [
          { id: '1', name: 'Item1', price: 10, quantity: 2 },
          { id: '2', name: 'Item2', price: 20, quantity: 3 }
        ],
        subtotal: 0,
        tax: 0,
        total: 0
      };
      
      // Act
      const count = getItemCount(cart);
      
      // Assert
      expect(count).toBe(5); // 2 + 3
    });
    
    test('returns zero for empty cart', () => {
      const cart: Cart = { items: [], subtotal: 0, tax: 0, total: 0 };
      const count = getItemCount(cart);
      expect(count).toBe(0);
    });
  });
});
                        

🎯 Quick Quiz

Question 1: What does the "Arrange" phase of the AAA pattern involve?

Question 2: In the testing pyramid, which type of test should you have the MOST of?

Question 3: What Jest matcher should you use to compare objects for equality?

Question 4: Which characteristic is TRUE about unit tests?

✨ Best Practices

Following these best practices will help you write effective, maintainable tests that provide real value to your codebase.

βœ… Do's

1. Write Descriptive Test Names

Test names should clearly describe what's being tested and what the expected outcome is.


// ❌ Bad - Vague test name
test('test user', () => { ... });

// βœ… Good - Clear and descriptive
test('returns user object with all required fields when given valid ID', () => { ... });
                

2. Test One Thing at a Time

Each test should verify a single behavior or scenario.


// ❌ Bad - Testing multiple unrelated things
test('user functions', () => {
  expect(createUser()).toBeDefined();
  expect(deleteUser()).toBe(true);
  expect(updateUser()).not.toThrow();
});

// βœ… Good - Separate tests for each behavior
test('createUser returns defined user object', () => { ... });
test('deleteUser returns true on successful deletion', () => { ... });
test('updateUser does not throw error', () => { ... });
                

3. Keep Tests Independent

Tests should not depend on each other or share state.


// ❌ Bad - Tests depend on execution order
let user: User;

test('creates user', () => {
  user = createUser();
  expect(user).toBeDefined();
});

test('updates user', () => {
  // Depends on previous test!
  const updated = updateUser(user, { name: 'New Name' });
  expect(updated.name).toBe('New Name');
});

// βœ… Good - Each test is independent
test('creates user', () => {
  const user = createUser();
  expect(user).toBeDefined();
});

test('updates user', () => {
  // Create fresh user for this test
  const user = createUser();
  const updated = updateUser(user, { name: 'New Name' });
  expect(updated.name).toBe('New Name');
});
                

4. Use Meaningful Test Data

Choose test data that makes the test's purpose clear.


// ❌ Bad - Unclear what's being tested
test('validates email', () => {
  expect(isValidEmail('test@test.com')).toBe(true);
});

// βœ… Good - Test data illustrates the scenario
test('accepts email with subdomain', () => {
  expect(isValidEmail('user@mail.example.com')).toBe(true);
});

test('rejects email without domain extension', () => {
  expect(isValidEmail('user@example')).toBe(false);
});
                

5. Use Setup and Teardown Appropriately

Extract common setup into beforeEach() hooks when appropriate.


describe('ShoppingCart', () => {
  let cart: Cart;
  
  beforeEach(() => {
    // Fresh cart for each test
    cart = {
      items: [],
      subtotal: 0,
      tax: 0,
      total: 0
    };
  });
  
  test('starts empty', () => {
    expect(cart.items).toHaveLength(0);
  });
  
  test('can add items', () => {
    const item = { id: '1', name: 'Widget', price: 10, quantity: 1 };
    const result = addItem(cart, item);
    expect(result.items).toHaveLength(1);
  });
});
                

❌ Don'ts

1. Don't Test Implementation Details

Test behavior, not internal implementation.

⚠️ Why This Matters

If you test implementation details, your tests will break every time you refactorβ€”even when the behavior stays the same. This makes refactoring painful and discourages improving code.


// ❌ Bad - Testing internal implementation
it('uses array.map internally', () => {
  const spy = vi.spyOn(Array.prototype, 'map'); // Jest: jest.spyOn(...)
  processItems([1, 2, 3]);
  expect(spy).toHaveBeenCalled();
});

// βœ… Good - Testing behavior/output
it('doubles all numbers in array', () => {
  const result = processItems([1, 2, 3]);
  expect(result).toEqual([2, 4, 6]);
});
                

2. Don't Write Tests That Can't Fail

If a test always passes, it's not providing value.


// ❌ Bad - This test can never fail
test('function is defined', () => {
  expect(myFunction).toBeDefined();
});

// βœ… Good - Actually tests behavior
test('function returns correct result', () => {
  const result = myFunction(5);
  expect(result).toBe(10);
});
                

3. Don't Use Magic Numbers Without Context

Make test values meaningful and self-documenting.


// ❌ Bad - What do these numbers mean?
test('calculates price', () => {
  expect(calculatePrice(5, 10)).toBe(52.5);
});

// βœ… Good - Clear what's being tested
test('calculates total price with tax', () => {
  const quantity = 5;
  const pricePerItem = 10;
  const taxRate = 0.05; // 5% tax
  const expectedTotal = 50 * 1.05; // 52.5
  
  const result = calculatePrice(quantity, pricePerItem, taxRate);
  expect(result).toBe(expectedTotal);
});
                

4. Don't Ignore Flaky Tests

Fix or remove tests that randomly fail. Flaky tests erode trust in your entire test suite.

⚠️ Dealing with Flaky Tests

Common causes of flaky tests:

  • Timing issues (use proper async handling)
  • Shared state between tests
  • External dependencies (mock them)
  • Random data (use fixed test data)

5. Don't Skip Writing Tests for "Simple" Code

Even simple code can have bugs. Plus, tests serve as documentation.


// "Too simple to test?" Think again!
function isEven(num: number): boolean {
  return num % 2 === 0;
}

// Test anyway - catches edge cases and documents behavior
test('returns true for even numbers', () => {
  expect(isEven(2)).toBe(true);
  expect(isEven(0)).toBe(true);
  expect(isEven(-4)).toBe(true);
});

test('returns false for odd numbers', () => {
  expect(isEven(1)).toBe(false);
  expect(isEven(-3)).toBe(false);
});
                

πŸ’‘ Pro Tips

1. Follow the F.I.R.S.T. Principles

Good tests are:

  • Fast - Run quickly (milliseconds)
  • Isolated - Don't depend on other tests
  • Repeatable - Same result every time
  • Self-validating - Pass or fail, no manual checking
  • Timely - Written close to when code is written

2. Use Test Coverage as a Guide, Not a Goal

100% code coverage doesn't mean perfect tests. Focus on testing important behaviors.

πŸ’‘ Coverage Sweet Spot

Aim for:

  • 80-90% coverage for utility functions and business logic
  • 60-80% coverage overall is often good enough
  • 100% coverage of critical paths (payment, authentication, etc.)

3. Read Test Failures Carefully

Vitest and Jest both provide excellent error messages with clear diffs. Read them fully β€” they often tell you exactly what's wrong and show the expected-vs-received values side by side.

4. Refactor Tests Like Production Code

Test code deserves the same care as production code. Keep it clean, DRY, and maintainable.

5. Use Descriptive Helper Functions

Extract common test patterns into well-named helpers.


// Helper function makes tests clearer
function createTestUser(overrides = {}): User {
  return {
    id: '1',
    name: 'Test User',
    email: 'test@example.com',
    ...overrides
  };
}

test('handles premium user', () => {
  const premiumUser = createTestUser({ isPremium: true });
  expect(calculateDiscount(premiumUser)).toBe(0.10);
});
                

πŸ“š Summary

πŸŽ‰ Key Takeaways

  • Testing matters: Tests provide confidence, documentation, catch bugs early, improve design, and enable team collaboration
  • Three types of tests: Unit tests (50-70%), integration tests (20-30%), E2E tests (10-20%) form the testing pyramid
  • Vitest is the modern default: Vite-native, TypeScript-ready, and Jest-compatible, so the same skills carry over to both runners
  • AAA pattern works: Arrange-Act-Assert provides clear structure for writing tests
  • Start simple: Begin with unit tests for utility functions before moving to complex scenarios
  • Test behavior, not implementation: Focus on what code does, not how it does it
  • Keep tests independent: Each test should run successfully in isolation
  • Use descriptive names: Test names should clearly explain what's being verified

πŸ“š Additional Resources

πŸš€ What's Next?

In the next lesson, we'll dive into React Testing Library and learn how to test React components. You'll discover:

  • Testing Library's philosophy of testing user behavior
  • Rendering components in tests
  • Querying elements (getBy, findBy, queryBy)
  • Simulating user interactions
  • Testing asynchronous components
  • Best practices for component testing
πŸ’‘ Remember: Testing is a skill that improves with practice. Don't worry if your first tests feel awkward or take time to write. Every test you write makes you a better developer. Start small, test often, and gradually build your testing confidence!

πŸŽ‰ Congratulations!

You've completed Testing Fundamentals! You now understand why testing matters, know the different types of tests, can set up Vitest, and write your first unit tests with confidence. You're building a critical skill that will serve you throughout your entire career as a developer.

Keep practicing, and remember: the best time to start writing tests is now!