๐งช 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 returntrue - Testing a calculation:
calculateTotal([10, 20, 30])should return60 - 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.
๐ญ
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:
๐ฌ
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.tsor*.test.tsx- Right next to your source files*.spec.tsor*.spec.tsx- Alternative convention__tests__/*.tsor__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 useit()โ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:
- Create a file called
stringUtils.ts - Implement the following functions:
capitalize(str: string): string- Capitalizes first lettertruncate(str: string, maxLength: number): string- Truncates string with "..."isPalindrome(str: string): boolean- Checks if string is palindrome (ignore case/spaces)
- Create
stringUtils.test.tsand write tests for each function - 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:
- Create
dateUtils.tswith these functions:formatDate(date: Date): string- Format as "YYYY-MM-DD"isWeekend(date: Date): boolean- Check if Saturday or SundaydaysBetween(date1: Date, date2: Date): number- Calculate days between dates
- Write comprehensive tests in
dateUtils.test.ts - 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:
- Create
cart.tswith 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 - Write tests for each function
- Test edge cases: empty cart, removing non-existent items, zero prices
- 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
- Jest Official Documentation - Comprehensive guide to Jest features
- React Testing Library - Learn to test React components (coming in next lesson!)
- Martin Fowler on Test Pyramid - Deep dive into testing strategy
- Kent C. Dodds on Testing - Excellent articles on testing philosophy
- JavaScript Testing Best Practices - Comprehensive testing guide
๐ 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!