π§ͺ Lesson 9.1: Testing Fundamentals
Master the foundations of testing React 19 applications with TypeScript. Learn why testing matters, understand the different kinds of tests, and write your first unit tests with Vitest β the fast, Vite-native runner that speaks TypeScript out of the box.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain why testing is essential for modern web applications
- Identify and describe different types of tests (unit, integration, E2E)
- Understand the testing pyramid (and the testing trophy) and how to balance your test suite
- Set up Vitest for testing TypeScript code β and recognise the near-identical Jest API
- Write your first unit tests using the AAA (Arrange-Act-Assert) pattern with
describe/it/expect - Apply proper TypeScript typing to test functions and expectations
Estimated Time: 60-75 minutes
Project: Write comprehensive unit tests for utility functions
In This Lesson
π Introduction to Testing
Welcome to Module 9! You've built amazing React applications with sophisticated features like forms, routing, state management, and data fetching. But there's a crucial skill that separates good developers from great ones: testing.
Testing isn't just about finding bugs (though it does that too). It's about building confidence in your code. It's about creating a safety net that allows you to refactor fearlessly, add new features quickly, and sleep well at night knowing your application works as expected.
π What is Testing?
Testing is the process of running your code with specific inputs and verifying that it produces the expected outputs and behaviours. In software development, automated tests are little programs that check whether your application code still works correctly β and, crucially, they re-run in seconds every time you change something.
Think of testing like this: imagine you're building a bridge. You wouldn't just build it and hope it holds up. You'd test the materials, test the design with scale models, test the construction at each stage, and finally load-test the complete bridge before opening it to traffic. Software testing works the same way β we verify our code at multiple levels so that we're not just hoping it works. And unlike manually clicking through your app after every change, an automated test suite does that verification for you, tirelessly, on every commit.
π‘ The Testing Mindset
Testing changes how you write code. When you know you'll need to test your functions, you naturally write them to be more modular, focused, and easier to reason about. This makes your entire codebase betterβeven if you never ran a single test!
π€ Why Testing Matters
Let's be honest: writing tests takes time. So why should you invest that time instead of just building more features? Here are compelling reasons why testing is essential:
π‘οΈ Confidence and Safety
Tests act as a safety net. When you modify code, tests immediately tell you if you broke something. This confidence allows you to:
- Refactor boldly: Improve your code structure without fear
- Add features quickly: Know that new code doesn't break existing functionality
- Upgrade dependencies: Update libraries with confidence that everything still works
π Documentation
Good tests serve as living documentation. They show other developers (and future you) exactly how your code is supposed to work. Instead of reading through implementation details, developers can look at tests to understand:
- What inputs a function expects
- What outputs it produces
- How edge cases are handled
- What errors might be thrown
β Real-World Example
At a major tech company, a developer needed to refactor a critical payment processing function. Without tests, this would have been terrifyingβone mistake could mean charging customers incorrectly. But because the function had comprehensive tests, the refactoring took just a few hours, and the tests confirmed that all payment scenarios still worked perfectly.
π Catch Bugs Early
Finding bugs during development is exponentially cheaper than finding them in production. Consider the cost of a bug at different stages:
| Stage | Time to Fix | Impact | Cost |
|---|---|---|---|
| During development (caught by tests) | Minutes | Developer only | Very Low |
| During code review | Hours | Multiple developers | Low |
| During QA testing | Hours to days | Development team blocked | Medium |
| In production | Days to weeks | Users affected, reputation damage | Very High |
π Better Design
When you write code with testing in mind (or better yet, write tests first), you naturally create better designs:
- Smaller functions: Easier to test, easier to understand
- Clear dependencies: Functions that are easy to test have clear inputs and outputs
- Loose coupling: Testable code isn't tangled up with other systems
- Single responsibility: Each function does one thing well
β οΈ Common Misconception
Myth: "Testing slows down development."
Reality: Tests slow down initial feature development by about 15-30%, but they speed up overall development by preventing bugs, enabling faster refactoring, and reducing debugging time. Over the lifetime of a project, tests save tremendous amounts of time.
π₯ Team Collaboration
Tests make it easier for teams to work together:
- Onboarding: New team members can understand how code works by reading tests
- Code reviews: Reviewers can verify behavior without manually testing
- Parallel development: Multiple developers can work on the same codebase without stepping on each other's toes
- Trust: Team members trust that changes won't break their work
π¬ Industry Perspective: "The best time to start writing tests is at the beginning of a project. The second-best time is right now." β Every experienced developer who learned this lesson the hard way
π― Types of Tests
Not all tests are created equal. Different types of tests serve different purposes and test different aspects of your application. Understanding these differences helps you choose the right tool for the job.
1. Unit Tests π¬
Unit tests are the foundation of your test suite. They test individual units of code in isolationβtypically a single function or method.
π Unit Test
Unit Test: A test that verifies a small, isolated piece of code (a "unit") works correctly. Unit tests focus on a single function, method, or component in complete isolation from the rest of the application.
Characteristics of Unit Tests:
- Fast: Run in milliseconds
- Isolated: No dependencies on databases, APIs, or file systems
- Focused: Test one thing at a time
- Deterministic: Always produce the same result given the same input
- Numerous: You'll have hundreds or thousands of these
Example scenarios for unit tests:
- Testing a function that formats currency:
formatCurrency(1234.56)should return"$1,234.56" - Testing a validation function:
isValidEmail("test@example.com")should 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. In React, a test that renders a form, types into it, and checks that submitting calls the right handler is a classic integration test.
Characteristics of Integration Tests:
- Moderate speed: Slower than unit tests, but still relatively fast
- Some real dependencies: May use real databases or APIs (or realistic mocks)
- Broader scope: Test multiple components working together
- More realistic: Closer to how users actually interact with your app
- Moderate quantity: You'll have dozens to hundreds of these
Example scenarios for integration tests:
- Testing a form that validates input and submits to an API
- Testing a shopping cart that updates quantities and calculates totals
- Testing a search feature that filters results and updates the UI
- Testing navigation between pages in your app
- Testing that multiple React components work together to display a dashboard
π‘ The Key Difference
Unit tests ask: "Does this function work correctly by itself?"
Integration tests ask: "Do these components work correctly together?"
3. End-to-End (E2E) Tests π
End-to-End tests simulate real user scenarios from start to finish. They test your entire application stack as a user would experience it.
π End-to-End Test
E2E Test: A test that simulates a complete user workflow through your application from beginning to end. E2E tests interact with your app just like a real user would, clicking buttons, filling forms, and verifying results β usually driving a real browser via a tool like Playwright or Cypress.
Characteristics of E2E Tests:
- Slow: Can take seconds or minutes to run
- Full stack: Test everything togetherβfrontend, backend, database
- User-centric: Test complete user workflows
- Can be brittle: Small UI changes can break tests
- Few but important: You'll have a handful to a few dozen of these
Example scenarios for E2E tests:
- Testing a complete user registration flow: sign up β verify email β log in β see dashboard
- Testing an e-commerce purchase: browse products β add to cart β checkout β payment β confirmation
- Testing a blog workflow: log in β create post β publish β verify it appears on homepage
- Testing critical user journeys that must always work
π‘ Tools for Each Level
Each layer has its own go-to tools in a modern React + TypeScript project:
- Unit & integration: Vitest (or Jest) as the runner, plus React Testing Library for rendering components.
- End-to-end: Playwright or Cypress, which drive a real browser.
This lesson focuses on the base of the pyramid β unit tests with Vitest β because that's where you'll spend most of your testing time.
β οΈ E2E Test Considerations
E2E tests are powerful but expensive to write and maintain. Use them sparingly for critical user paths. Don't try to test every scenario with E2E testsβthat's what unit and integration tests are for!
Comparison Table
| Aspect | Unit Tests | Integration Tests | E2E Tests |
|---|---|---|---|
| Scope | Single function/component | Multiple components together | Entire application |
| Speed | Very fast (milliseconds) | Moderate (seconds) | Slow (seconds to minutes) |
| Cost to Write | Low | Medium | High |
| Maintenance | Easy | Moderate | Difficult |
| Confidence | Low to Medium | Medium to High | Very High |
| Quantity | Hundreds to thousands | Dozens to hundreds | Handful to few dozen |
| Failures | Pinpoint exact issue | Narrow down to area | Know something is broken |
πΊ The Testing Pyramid
The testing pyramid is a foundational concept in test strategy. It visualizes how to balance different types of tests to create an effective, efficient, and maintainable test suite.
π
10-20%
Complete user workflows"] INT["Integration Tests
π
20-30%
Components working together"] UNIT["Unit Tests
π¬
50-70%
Individual functions"] E2E --> INT INT --> UNIT style E2E fill:#ff6b6b,stroke:#c92a2a,stroke-width:2px,color:#fff style INT fill:#4dabf7,stroke:#1971c2,stroke-width:2px,color:#fff style UNIT fill:#51cf66,stroke:#2f9e44,stroke-width:2px,color:#fff
Understanding the Pyramid
π¬ Base: Unit Tests (50-70% of your tests)
Unit tests form the foundation of your testing pyramid. They should be:
- Numerous: Test every important function and edge case
- Fast: Your entire unit test suite should run in seconds
- Comprehensive: Cover all business logic thoroughly
- Easy to write: If they're hard to write, your code might need refactoring
β Why So Many Unit Tests?
Unit tests are cheap to write and maintain, run instantly, and pinpoint exactly what's broken. They give you rapid feedback during development and catch bugs before they spread to other parts of your application.
π Middle: Integration Tests (20-30% of your tests)
Integration tests verify that your components work together correctly. They should:
- Test boundaries: Focus on how units interact at their interfaces
- Be realistic: Use real implementations when possible
- Cover workflows: Test common user scenarios
- Balance speed and realism: Fast enough to run frequently, realistic enough to catch real issues
π Top: E2E Tests (10-20% of your tests)
E2E tests validate critical user journeys. They should:
- Test happy paths: Focus on the most important user workflows
- Simulate real users: Click, type, navigate like actual users would
- Be selective: Only test the most critical features
- Be resilient: Written to survive minor UI changes
Why the Pyramid Shape?
The pyramid shape exists for good reasons:
π‘ The Math Behind the Pyramid
Speed: 1,000 unit tests might run in 5 seconds. 100 integration tests might take 30 seconds. 10 E2E tests could take 5 minutes. The pyramid keeps your total test runtime manageable.
Maintenance: Unit tests rarely break when you change unrelated code. E2E tests can break from any UI change. More unit tests = less maintenance burden.
Debugging: When a unit test fails, you know exactly which function is broken. When an E2E test fails, you might need to debug the entire application to find the issue.
π‘ A Modern Twist: The Testing Trophy
In the React community you'll often hear about the testing trophy (popularised by Kent C. Dodds) rather than the pyramid. It keeps a thin base of static checks (TypeScript and ESLint catch a whole class of bugs before a single test runs) and then makes integration tests the fattest layer β the reasoning being that testing components the way a user actually uses them gives you the most confidence per line of test code.
The pyramid and the trophy aren't enemies. Both say the same core thing: lean on many fast, cheap tests and use slow end-to-end tests sparingly. With TypeScript, you already get the trophy's static-analysis base for free.
β Anti-Pattern: The Ice Cream Cone
Some teams accidentally create an inverted pyramidβlots of E2E tests, few unit tests. This is called the "ice cream cone" anti-pattern, and it's problematic:
π¬
10-20%
Not enough!"] INT2["Integration Tests
π
20-30%"] E2E2["E2E Tests
π
50-70%
Too many!"] UNIT2 --> INT2 INT2 --> E2E2 style UNIT2 fill:#51cf66,stroke:#2f9e44,stroke-width:2px,color:#fff style INT2 fill:#4dabf7,stroke:#1971c2,stroke-width:2px,color:#fff style E2E2 fill:#ff6b6b,stroke:#c92a2a,stroke-width:2px,color:#fff
β οΈ Problems with the Ice Cream Cone
- Slow feedback: Tests take forever to run
- Difficult debugging: When tests fail, it's hard to find the root cause
- High maintenance: UI changes break many tests
- Flaky tests: E2E tests are prone to random failures
- Developer frustration: Teams stop trusting or running tests
Practical Application
Let's say you're building a task management app. Here's how you might apply the testing pyramid:
π Example: Task Manager Testing Strategy
Unit Tests (70%):
- Date formatting functions
- Task validation logic
- Priority calculation
- Individual React components
- Custom hooks
Integration Tests (25%):
- Adding a task updates the list
- Filtering works with the task list
- Form validation integrates with submit
- State management works across components
E2E Tests (5%):
- Complete flow: Create account β Add task β Mark complete β View in completed list
- Critical path: Login β Create project β Add tasks β Share with team
π‘ Pro Tip: Start with the pyramid in mind, but don't be dogmatic about the exact percentages. The right balance depends on your application. A simple utility library might be 95% unit tests, while a complex UI-heavy app might have more integration tests.
βοΈ Setting Up Vitest
To run tests, you need a test runner: a tool that finds your test files, executes them, reports the results, and gives you the assertion helpers to check your expectations. For a modern React + TypeScript project built with Vite, the natural choice is Vitest.
π What is Vitest?
Vitest is a fast, Vite-native testing framework. It reuses your project's existing Vite config, so it understands TypeScript, JSX, ESM, and path aliases with zero extra setup. It bundles a test runner, an assertion library, mocking (vi), and coverage β everything you need in one package.
Why Vitest?
Vitest has become the default for Vite-based React apps for several compelling reasons:
- TypeScript & ESM out of the box: no
ts-jestor Babel plumbing to configure - Reuses your Vite config: aliases, plugins, and env handling "just work"
- Blazing fast: runs tests in parallel and only re-runs what changed in watch mode
- Jest-compatible API:
describe,it/test,expect, and matchers behave the same - Built-in mocking & coverage: use
vifor mocks and--coveragefor reports - Great error messages & UI: clear diffs, plus an optional browser dashboard (
@vitest/ui)
π‘ Vitest vs Jest
Jest (created at Facebook/Meta) is the classic, battle-tested runner and still powers countless codebases β including Create React App projects. The good news: Vitest was deliberately built with a near-identical API. Almost everything in this lesson β describe, it, expect, matchers, the AAA pattern β reads the same in both. The main differences are the config file and that Vitest's mock helper is vi where Jest's is jest. Learn one and you effectively know both.
Installing Vitest with TypeScript
In a Vite + React + TypeScript project, add Vitest and the React Testing Library tools as dev dependencies:
# Vitest test runner + jsdom to simulate a browser environment
npm install --save-dev vitest jsdom
# React Testing Library for rendering components in tests
npm install --save-dev @testing-library/react @testing-library/jest-dom
npm install --save-dev @testing-library/user-event
# Optional: the browser-based Vitest dashboard
npm install --save-dev @vitest/ui
Configuring Vitest
Add a test block to your vite.config.ts (Vitest reads the same file, so there's usually no separate config to maintain):
// vite.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
// Use jsdom so component tests have a DOM to render into
environment: 'jsdom',
// Expose describe / it / expect without importing them everywhere
globals: true,
// Runs once before the test suite (custom matchers, cleanup, etc.)
setupFiles: './src/test/setup.ts',
// Collect coverage from your source files
coverage: {
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/**/*.d.ts', 'src/main.tsx'],
},
},
});
Setup File
Create src/test/setup.ts to wire up the extra DOM matchers (like toBeInTheDocument()) and reset the DOM between tests:
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
// Unmount rendered components after every test to keep them isolated
afterEach(() => {
cleanup();
});
Update package.json
Add test scripts to your package.json:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
π‘ Watch mode is the default
Running npm test starts Vitest in watch mode: it stays open and re-runs only the tests affected by your latest change. Use vitest run (a single, one-shot pass) in CI, where you want the process to exit with a pass/fail status.
β Quick Setup Verification
After setup, run npm test to verify everything works. You should see a message like "No test files found" if you haven't written any tests yet β that's perfect!
File Naming Conventions
Vitest (and Jest) automatically discover test files using these naming patterns:
*.test.tsor*.test.tsxβ right next to your source files*.spec.tsor*.spec.tsxβ alternative convention__tests__/*.tsor__tests__/*.tsxβ inside a__tests__folder
π‘ Recommended Structure
For React components, place test files next to the component:
src/
components/
Button/
Button.tsx
Button.test.tsx
Button.css
For utility functions, you can use either approach (co-located or __tests__ folder).
βοΈ Writing Your First Test
Let's write our very first test! We'll start simple with a utility function, then build up to more complex scenarios.
A Simple Function to Test
Create a file called utils.ts:
// utils.ts
export function add(a: number, b: number): number {
return a + b;
}
export function subtract(a: number, b: number): number {
return a - b;
}
export function multiply(a: number, b: number): number {
return a * b;
}
Your First Test File
Now create utils.test.ts in the same directory:
// utils.test.ts
import { describe, it, expect } from 'vitest';
import { add, subtract, multiply } from './utils';
describe('math utils', () => {
it('adds two numbers correctly', () => {
const result = add(2, 3);
expect(result).toBe(5);
});
it('subtracts two numbers correctly', () => {
const result = subtract(10, 4);
expect(result).toBe(6);
});
it('multiplies two numbers correctly', () => {
const result = multiply(3, 4);
expect(result).toBe(12);
});
});
π‘ To import or not to import?
Because we set globals: true in the config, describe, it, and expect are available without importing them (exactly like Jest). Many teams still prefer the explicit import { describe, it, expect } from 'vitest' shown above β it makes the source of these functions obvious and plays nicely with editors and linters. Both styles are correct; pick one and be consistent.
Understanding Test Syntax
Let's break down what's happening in these tests:
π Test Anatomy
it('description of what you are testing', () => {
// Test code goes here
expect(actualValue).toBe(expectedValue);
});
it(): defines a test (test()is an exact alias β pick whichever reads better;it('...')tends to read like a sentence)- Description string: explains what the test does in plain English
- Callback function: contains the actual test code
expect(): creates an assertion about a value.toBe(): a "matcher" that checks whether values are equal
Running Your Tests
Run your tests with:
npm test
You should see output like this:
β src/utils.test.ts (3 tests) 4ms
β math utils > adds two numbers correctly
β math utils > subtracts two numbers correctly
β math utils > multiplies two numbers correctly
Test Files 1 passed (1)
Tests 3 passed (3)
Start at 10:24:07
Duration 312ms
π Congratulations!
You just wrote and ran your first automated tests! That green checkmark feeling is addictiveβyou'll want to see it all the time.
Common Matchers
Vitest ships a rich set of matchers for different kinds of assertions β and because the API mirrors Jest, every matcher below works identically in both runners:
| Matcher | Use Case | Example |
|---|---|---|
.toBe() |
Exact equality (===) | expect(2 + 2).toBe(4) |
.toEqual() |
Deep equality for objects/arrays | expect(obj).toEqual({a: 1}) |
.toBeTruthy() |
Value is truthy | expect("hello").toBeTruthy() |
.toBeFalsy() |
Value is falsy | expect(0).toBeFalsy() |
.toBeNull() |
Value is null | expect(value).toBeNull() |
.toBeUndefined() |
Value is undefined | expect(value).toBeUndefined() |
.toContain() |
Array/string contains item | expect([1,2,3]).toContain(2) |
.toHaveLength() |
Array/string length | expect(arr).toHaveLength(3) |
.toThrow() |
Function throws error | expect(fn).toThrow() |
.toBeGreaterThan() |
Numeric comparison | expect(10).toBeGreaterThan(5) |
Negating Matchers
You can negate any matcher with .not:
test('number is not zero', () => {
expect(5).not.toBe(0);
});
test('array does not contain item', () => {
expect([1, 2, 3]).not.toContain(4);
});
π― The AAA Pattern
The AAA (Arrange-Act-Assert) pattern is a widely-used structure for writing clear, maintainable tests. It helps organize your test code and makes tests easier to understand.
π AAA Pattern
Arrange-Act-Assert (AAA) is a pattern for structuring tests:
- Arrange: Set up the test data and conditions
- Act: Execute the code being tested
- Assert: Verify the results are correct
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
it('uses array.map internally', () => {
const spy = vi.spyOn(Array.prototype, 'map'); // Jest: jest.spyOn(...)
processItems([1, 2, 3]);
expect(spy).toHaveBeenCalled();
});
// β
Good - Testing behavior/output
it('doubles all numbers in array', () => {
const result = processItems([1, 2, 3]);
expect(result).toEqual([2, 4, 6]);
});
2. Don't Write Tests That Can't Fail
If a test always passes, it's not providing value.
// β Bad - This test can never fail
test('function is defined', () => {
expect(myFunction).toBeDefined();
});
// β
Good - Actually tests behavior
test('function returns correct result', () => {
const result = myFunction(5);
expect(result).toBe(10);
});
3. Don't Use Magic Numbers Without Context
Make test values meaningful and self-documenting.
// β Bad - What do these numbers mean?
test('calculates price', () => {
expect(calculatePrice(5, 10)).toBe(52.5);
});
// β
Good - Clear what's being tested
test('calculates total price with tax', () => {
const quantity = 5;
const pricePerItem = 10;
const taxRate = 0.05; // 5% tax
const expectedTotal = 50 * 1.05; // 52.5
const result = calculatePrice(quantity, pricePerItem, taxRate);
expect(result).toBe(expectedTotal);
});
4. Don't Ignore Flaky Tests
Fix or remove tests that randomly fail. Flaky tests erode trust in your entire test suite.
β οΈ Dealing with Flaky Tests
Common causes of flaky tests:
- Timing issues (use proper async handling)
- Shared state between tests
- External dependencies (mock them)
- Random data (use fixed test data)
5. Don't Skip Writing Tests for "Simple" Code
Even simple code can have bugs. Plus, tests serve as documentation.
// "Too simple to test?" Think again!
function isEven(num: number): boolean {
return num % 2 === 0;
}
// Test anyway - catches edge cases and documents behavior
test('returns true for even numbers', () => {
expect(isEven(2)).toBe(true);
expect(isEven(0)).toBe(true);
expect(isEven(-4)).toBe(true);
});
test('returns false for odd numbers', () => {
expect(isEven(1)).toBe(false);
expect(isEven(-3)).toBe(false);
});
π‘ Pro Tips
1. Follow the F.I.R.S.T. Principles
Good tests are:
- Fast - Run quickly (milliseconds)
- Isolated - Don't depend on other tests
- Repeatable - Same result every time
- Self-validating - Pass or fail, no manual checking
- Timely - Written close to when code is written
2. Use Test Coverage as a Guide, Not a Goal
100% code coverage doesn't mean perfect tests. Focus on testing important behaviors.
π‘ Coverage Sweet Spot
Aim for:
- 80-90% coverage for utility functions and business logic
- 60-80% coverage overall is often good enough
- 100% coverage of critical paths (payment, authentication, etc.)
3. Read Test Failures Carefully
Vitest and Jest both provide excellent error messages with clear diffs. Read them fully β they often tell you exactly what's wrong and show the expected-vs-received values side by side.
4. Refactor Tests Like Production Code
Test code deserves the same care as production code. Keep it clean, DRY, and maintainable.
5. Use Descriptive Helper Functions
Extract common test patterns into well-named helpers.
// Helper function makes tests clearer
function createTestUser(overrides = {}): User {
return {
id: '1',
name: 'Test User',
email: 'test@example.com',
...overrides
};
}
test('handles premium user', () => {
const premiumUser = createTestUser({ isPremium: true });
expect(calculateDiscount(premiumUser)).toBe(0.10);
});
π Summary
π Key Takeaways
- Testing matters: Tests provide confidence, documentation, catch bugs early, improve design, and enable team collaboration
- Three types of tests: Unit tests (50-70%), integration tests (20-30%), E2E tests (10-20%) form the testing pyramid
- Vitest is the modern default: Vite-native, TypeScript-ready, and Jest-compatible, so the same skills carry over to both runners
- AAA pattern works: Arrange-Act-Assert provides clear structure for writing tests
- Start simple: Begin with unit tests for utility functions before moving to complex scenarios
- Test behavior, not implementation: Focus on what code does, not how it does it
- Keep tests independent: Each test should run successfully in isolation
- Use descriptive names: Test names should clearly explain what's being verified
π Additional Resources
- Vitest Official Documentation - Comprehensive guide to the Vite-native runner
- Jest Official Documentation - The classic alternative with a near-identical API
- React Testing Library - Learn to test React components (coming in the next lesson!)
- Playwright - Modern end-to-end browser testing
- Martin Fowler on the Test Pyramid - Deep dive into testing strategy
- Kent C. Dodds on Testing - Origin of the testing trophy and testing philosophy
π What's Next?
In the next lesson, we'll dive into React Testing Library and learn how to test React components. You'll discover:
- Testing Library's philosophy of testing user behavior
- Rendering components in tests
- Querying elements (getBy, findBy, queryBy)
- Simulating user interactions
- Testing asynchronous components
- Best practices for component testing
π‘ Remember: Testing is a skill that improves with practice. Don't worry if your first tests feel awkward or take time to write. Every test you write makes you a better developer. Start small, test often, and gradually build your testing confidence!
π Congratulations!
You've completed Testing Fundamentals! You now understand why testing matters, know the different types of tests, can set up Vitest, and write your first unit tests with confidence. You're building a critical skill that will serve you throughout your entire career as a developer.
Keep practicing, and remember: the best time to start writing tests is now!