Skip to main content

๐Ÿงช Lesson 9.1: Testing Fundamentals

Master the foundations of testing React applications with TypeScript. Learn why testing matters, understand different types of tests, and write your first unit tests with confidence.

๐ŸŽฏ 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 how to balance your test suite
  • Set up Jest for testing TypeScript code
  • Write your first unit tests using the AAA (Arrange-Act-Assert) pattern
  • 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 executing your code with specific inputs and verifying that it produces the expected outputs and behaviors. In software development, automated tests are programs that check if your application code works correctly.

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 models, test the construction at each stage, and finally test the complete bridge before opening it to traffic. Software testing works the same wayโ€”we verify our code at multiple levels to ensure it works correctly.

๐Ÿ’ก 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.

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.

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

โš ๏ธ 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.

โŒ 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 Jest

Jest is a delightful JavaScript testing framework created by Facebook. It's the most popular choice for testing React applications because it's fast, has great developer experience, and comes with everything you need built-in.

๐Ÿ“– What is Jest?

Jest is a JavaScript testing framework that provides test runners, assertion libraries, mocking capabilities, and code coverage tools all in one package. It works seamlessly with TypeScript and React.

Why Jest?

Jest has become the go-to testing framework for React applications for several compelling reasons:

  • Zero Configuration: Works out of the box with most setups
  • Fast: Runs tests in parallel for speed
  • Snapshot Testing: Great for testing React components
  • Built-in Mocking: Powerful mocking capabilities without extra libraries
  • Code Coverage: Built-in coverage reports
  • Great Error Messages: Clear, helpful feedback when tests fail
  • Watch Mode: Automatically re-runs tests as you code

Installing Jest with TypeScript

Let's set up Jest in a React TypeScript project. If you're using Vite (which we've been using throughout this course), here's how to add Jest:


# Install Jest and TypeScript support
npm install --save-dev jest @types/jest ts-jest

# Install testing utilities for React
npm install --save-dev @testing-library/react @testing-library/jest-dom
npm install --save-dev @testing-library/user-event

# If using Vite, also install
npm install --save-dev @vitest/ui vitest jsdom
                

๐Ÿ’ก Vitest vs Jest

If you're using Vite, you might want to use Vitest instead of Jest. Vitest is a newer testing framework designed specifically for Vite projects. It has a Jest-compatible API, so everything you learn here applies to both! For this lesson, we'll focus on Jest concepts that work with both frameworks.

Configuring Jest

Create a jest.config.js file in your project root:


module.exports = {
  // Use ts-jest for TypeScript files
  preset: 'ts-jest',
  
  // Set the test environment to jsdom (simulates a browser)
  testEnvironment: 'jsdom',
  
  // Setup files to run before tests
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  
  // Module name mapping for CSS and asset imports
  moduleNameMapper: {
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
    '\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.js'
  },
  
  // Coverage configuration
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts',
    '!src/main.tsx',
    '!src/vite-env.d.ts'
  ],
  
  // Transform files
  transform: {
    '^.+\\.tsx?$': 'ts-jest'
  }
};
                

Setup File

Create a jest.setup.js file to configure testing utilities:


// Add custom jest matchers from jest-dom
import '@testing-library/jest-dom';

// Optional: Configure testing library
import { configure } from '@testing-library/react';

configure({ testIdAttribute: 'data-testid' });
                

Update package.json

Add test scripts to your package.json:


{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  }
}
                

โœ… Quick Setup Verification

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

File Naming Conventions

Jest automatically finds 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 - In 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 { add, subtract, multiply } from './utils';

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

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

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

Understanding Test Syntax

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

๐Ÿ“– Test Anatomy

test('description of what you're testing', () => {
  // Test code goes here
  expect(actualValue).toBe(expectedValue);
});
  • test(): Defines a test (you can also use it()โ€”they're identical)
  • 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 if values are equal

Running Your Tests

Run your tests with:


npm test
                

You should see output like this:


 PASS  ./utils.test.ts
  โœ“ adds two numbers correctly (2 ms)
  โœ“ subtracts two numbers correctly (1 ms)
  โœ“ multiplies two numbers correctly (1 ms)

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Time:        1.234 s
                

๐ŸŽ‰ 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 Jest Matchers

Jest provides many matchers for different types of assertions:

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

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
test('uses array.map internally', () => {
  const spy = jest.spyOn(Array.prototype, 'map');
  processItems([1, 2, 3]);
  expect(spy).toHaveBeenCalled();
});

// โœ… Good - Testing behavior/output
test('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

Jest provides excellent error messages. Read them fullyโ€”they often tell you exactly what's wrong.

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 (70%), integration tests (20-30%), E2E tests (10-20%) form the testing pyramid
  • Jest is powerful: It provides everything you need for testing JavaScript/TypeScript applications
  • 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 Jest, 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!