Testing Strategies for Modern Web Applications: Unit, Integration, E2E, and Visual Testing
Master comprehensive testing strategies for web applications. Learn unit, integration, E2E, and visual testing with Jest, Vitest, Playwright, and best practices.
Table of Contents
- Introduction
- Understanding the Testing Pyramid
- Unit Testing: Foundation of Quality Code
- Integration Testing: Testing Component Interactions
- End-to-End Testing: Testing User Flows
- Visual Testing: Ensuring UI Consistency
- Choosing the Right Testing Tools
- Testing Patterns and Best Practices
- Test Organization and Structure
- Mocking and Test Doubles
- Testing Asynchronous Code
- Performance Testing
- Continuous Integration and Testing
- Common Testing Pitfalls and How to Avoid Them
- Conclusion
Introduction
Testing is one of the most critical aspects of modern web development. It ensures your code works as expected, prevents regressions, and gives you confidence when refactoring or adding new features. However, with so many testing strategies, tools, and approaches available, it can be overwhelming to know where to start or how to structure your test suite effectively.
Modern web applications are complex systems with multiple layers: user interfaces, API integrations, state management, and business logic. Each layer requires different testing approaches. Understanding when to use unit tests, integration tests, end-to-end tests, and visual tests is crucial for building a robust testing strategy that catches bugs early without slowing down development.
This comprehensive guide covers all aspects of testing modern web applications. You’ll learn about the testing pyramid, how to write effective tests at each level, choose the right tools, and implement testing patterns that scale with your application. Whether you’re testing React components, API endpoints, or complete user workflows, this guide provides practical examples and best practices you can apply immediately.
Understanding the Testing Pyramid
The testing pyramid is a conceptual model that helps you understand how to distribute your testing efforts across different test types. It visualizes the ideal distribution of tests in your test suite.
The Three Layers
/\ /E2E\ ← Few tests, slow, expensive /------\ /Integration\ ← Some tests, medium speed /------------\ / Unit Tests \ ← Many tests, fast, cheap /----------------\Unit Tests (Base of Pyramid):
- Volume: Many (70-80% of tests)
- Speed: Fast (milliseconds)
- Scope: Single function, component, or module
- Purpose: Verify individual units work correctly in isolation
- Cost: Low (quick to write and run)
Integration Tests (Middle Layer):
- Volume: Some (15-20% of tests)
- Speed: Medium (seconds)
- Scope: Multiple components or modules working together
- Purpose: Verify components/modules integrate correctly
- Cost: Medium (moderate complexity)
End-to-End Tests (Top of Pyramid):
- Volume: Few (5-10% of tests)
- Speed: Slow (minutes)
- Scope: Complete user workflows
- Purpose: Verify the application works from user’s perspective
- Cost: High (expensive to write and maintain)
Why the Pyramid Works
✅ Fast feedback: Most tests run quickly, giving you immediate feedback
✅ Cost-effective: Focus expensive tests on critical paths
✅ Maintainable: Unit tests are easier to maintain than E2E tests
✅ Confidence: Different test types catch different types of bugs
❌ Inverted pyramid: Too many E2E tests lead to slow test suites and maintenance burden
❌ Ice cream cone: Too many integration tests without enough unit tests
Real-World Distribution
For a typical web application, you might have:
// Example distribution for a medium-sized app{ unitTests: 500, // 75% - Fast, test individual functions/components integrationTests: 100, // 15% - Test component interactions e2eTests: 50 // 10% - Test critical user flows}💡 Tip: Start with unit tests for new features, then add integration tests for complex interactions, and finally add E2E tests for critical user journeys.
Unit Testing: Foundation of Quality Code
Unit testing is the practice of testing individual units of code in isolation. A unit can be a function, a class method, or a React component. Unit tests should be fast, isolated, and deterministic.
What Makes a Good Unit Test?
✅ Fast: Runs in milliseconds
✅ Isolated: Doesn’t depend on external systems
✅ Deterministic: Same input always produces same output
✅ Focused: Tests one thing at a time
✅ Readable: Clear what it’s testing and why
Unit Testing Pure Functions
Pure functions are the easiest to test because they have no side effects and always return the same output for the same input.
// Pure function - easy to testfunction calculateTotal(items, taxRate) { const subtotal = items.reduce((sum, item) => sum + item.price, 0); return subtotal * (1 + taxRate);}
// Unit testdescribe("calculateTotal", () => { it("should calculate total with tax", () => { const items = [{ price: 10 }, { price: 20 }, { price: 30 }]; const taxRate = 0.1;
const result = calculateTotal(items, taxRate);
expect(result).toBe(66); // (10 + 20 + 30) * 1.1 = 66 });
it("should handle empty array", () => { const result = calculateTotal([], 0.1); expect(result).toBe(0); });
it("should handle zero tax rate", () => { const items = [{ price: 100 }]; const result = calculateTotal(items, 0); expect(result).toBe(100); });});Unit Testing React Components
When testing React components, focus on testing behavior, not implementation details. Use React Testing Library’s philosophy: “The more your tests resemble the way your software is used, the more confidence they can give you.”
// Component to testimport { useState } from "react";
function Counter({ initialValue = 0 }) { const [count, setCount] = useState(initialValue);
return ( <div> <button onClick={() => setCount(count - 1)} aria-label="decrement"> - </button> <span data-testid="count">{count}</span> <button onClick={() => setCount(count + 1)} aria-label="increment"> + </button> </div> );}
// Unit testimport { render, screen, fireEvent } from "@testing-library/react";import { describe, it, expect } from "vitest";import Counter from "./Counter";
describe("Counter", () => { it("should render with initial value", () => { render(<Counter initialValue={5} />); expect(screen.getByTestId("count")).toHaveTextContent("5"); });
it("should increment count when increment button is clicked", () => { render(<Counter />); const incrementButton = screen.getByLabelText("increment"); const countDisplay = screen.getByTestId("count");
fireEvent.click(incrementButton);
expect(countDisplay).toHaveTextContent("1"); });
it("should decrement count when decrement button is clicked", () => { render(<Counter initialValue={5} />); const decrementButton = screen.getByLabelText("decrement"); const countDisplay = screen.getByTestId("count");
fireEvent.click(decrementButton);
expect(countDisplay).toHaveTextContent("4"); });
it("should handle multiple clicks", () => { render(<Counter />); const incrementButton = screen.getByLabelText("increment");
fireEvent.click(incrementButton); fireEvent.click(incrementButton); fireEvent.click(incrementButton);
expect(screen.getByTestId("count")).toHaveTextContent("3"); });});Testing Custom Hooks
Testing custom hooks requires using @testing-library/react-hooks or React Testing Library’s renderHook utility.
// Custom hookimport { useState, useEffect } from "react";
function useDebounce(value, delay) { const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => { const handler = setTimeout(() => { setDebouncedValue(value); }, delay);
return () => { clearTimeout(handler); }; }, [value, delay]);
return debouncedValue;}
// Unit testimport { renderHook, waitFor } from "@testing-library/react";import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";import { useDebounce } from "./useDebounce";
describe("useDebounce", () => { beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.restoreAllMocks(); });
it("should return initial value immediately", () => { const { result } = renderHook(() => useDebounce("test", 500)); expect(result.current).toBe("test"); });
it("should debounce value changes", async () => { const { result, rerender } = renderHook( ({ value, delay }) => useDebounce(value, delay), { initialProps: { value: "initial", delay: 500 } }, );
expect(result.current).toBe("initial");
rerender({ value: "updated", delay: 500 }); expect(result.current).toBe("initial"); // Still initial
vi.advanceTimersByTime(500); await waitFor(() => { expect(result.current).toBe("updated"); }); });});Unit Testing Utilities and Helpers
Utility functions are perfect candidates for unit testing because they’re typically pure functions.
// Utility functionexport function formatCurrency(amount, currency = "USD", locale = "en-US") { return new Intl.NumberFormat(locale, { style: "currency", currency, }).format(amount);}
// Unit testimport { describe, it, expect } from "vitest";import { formatCurrency } from "./formatCurrency";
describe("formatCurrency", () => { it("should format USD currency by default", () => { expect(formatCurrency(1000)).toBe("$1,000.00"); });
it("should format EUR currency", () => { expect(formatCurrency(1000, "EUR")).toBe("€1,000.00"); });
it("should format with different locale", () => { expect(formatCurrency(1000, "EUR", "de-DE")).toBe("1.000,00 €"); });
it("should handle decimal amounts", () => { expect(formatCurrency(1234.56)).toBe("$1,234.56"); });
it("should handle zero", () => { expect(formatCurrency(0)).toBe("$0.00"); });});💡 Pro Tip: When writing unit tests, follow the AAA pattern: Arrange (set up), Act (execute), Assert (verify). This makes tests more readable and maintainable.
Integration Testing: Testing Component Interactions
Integration tests verify that multiple units work together correctly. In React applications, this typically means testing how components interact with each other, with context providers, or with external services.
What to Test in Integration Tests
✅ Component composition: How components work together
✅ Context providers: Components consuming context
✅ API integration: Components fetching and displaying data
✅ Form submissions: Complete form workflows
✅ Navigation: Routing and page transitions
Testing Component Composition
// Parent componentfunction UserProfile({ userId }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true);
useEffect(() => { fetchUser(userId).then((data) => { setUser(data); setLoading(false); }); }, [userId]);
if (loading) return <LoadingSpinner />; if (!user) return <ErrorMessage message="User not found" />;
return ( <div> <UserHeader user={user} /> <UserDetails user={user} /> <UserActions userId={userId} /> </div> );}
// Integration testimport { render, screen, waitFor } from "@testing-library/react";import { describe, it, expect, vi } from "vitest";import UserProfile from "./UserProfile";
// Mock the API callvi.mock("./api", () => ({ fetchUser: vi.fn(),}));
describe("UserProfile Integration", () => { it("should render user profile with all components", async () => { const mockUser = { id: "1", name: "John Doe", email: "john@example.com", avatar: "avatar.jpg", };
const { fetchUser } = await import("./api"); fetchUser.mockResolvedValue(mockUser);
render(<UserProfile userId="1" />);
// Wait for loading to complete await waitFor(() => { expect(screen.queryByText("Loading...")).not.toBeInTheDocument(); });
// Verify all components rendered expect(screen.getByText("John Doe")).toBeInTheDocument(); expect(screen.getByText("john@example.com")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /edit/i })).toBeInTheDocument(); });
it("should show error message when user fetch fails", async () => { const { fetchUser } = await import("./api"); fetchUser.mockRejectedValue(new Error("User not found"));
render(<UserProfile userId="999" />);
await waitFor(() => { expect(screen.getByText("User not found")).toBeInTheDocument(); }); });});Testing Context Providers
// Theme contextconst ThemeContext = createContext();
function ThemeProvider({ children }) { const [theme, setTheme] = useState("light");
const toggleTheme = () => { setTheme((prev) => (prev === "light" ? "dark" : "light")); };
return ( <ThemeContext.Provider value={{ theme, toggleTheme }}> {children} </ThemeContext.Provider> );}
// Component using contextfunction ThemeToggle() { const { theme, toggleTheme } = useContext(ThemeContext);
return ( <button onClick={toggleTheme} aria-label="toggle theme"> Current theme: {theme} </button> );}
// Integration testimport { render, screen, fireEvent } from "@testing-library/react";import { describe, it, expect } from "vitest";import { ThemeProvider } from "./ThemeProvider";import ThemeToggle from "./ThemeToggle";
describe("ThemeProvider Integration", () => { it("should provide theme context to children", () => { render( <ThemeProvider> <ThemeToggle /> </ThemeProvider>, );
expect(screen.getByText(/current theme: light/i)).toBeInTheDocument(); });
it("should toggle theme when button is clicked", () => { render( <ThemeProvider> <ThemeToggle /> </ThemeProvider>, );
const toggleButton = screen.getByLabelText("toggle theme"); fireEvent.click(toggleButton);
expect(screen.getByText(/current theme: dark/i)).toBeInTheDocument(); });});Testing Form Workflows
Forms are perfect for integration testing because they involve multiple interactions and state changes.
// Login form componentfunction LoginForm({ onSuccess }) { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => { e.preventDefault(); setLoading(true); setError("");
try { await login({ email, password }); onSuccess(); } catch (err) { setError(err.message); } finally { setLoading(false); } };
return ( <form onSubmit={handleSubmit}> <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" required /> <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" required /> {error && <div role="alert">{error}</div>} <button type="submit" disabled={loading}> {loading ? "Logging in..." : "Login"} </button> </form> );}
// Integration testimport { render, screen, fireEvent, waitFor } from "@testing-library/react";import { describe, it, expect, vi } from "vitest";import LoginForm from "./LoginForm";
vi.mock("./api", () => ({ login: vi.fn(),}));
describe("LoginForm Integration", () => { it("should submit form with valid credentials", async () => { const onSuccess = vi.fn(); const { login } = await import("./api"); login.mockResolvedValue({ token: "abc123" });
render(<LoginForm onSuccess={onSuccess} />);
fireEvent.change(screen.getByPlaceholderText("Email"), { target: { value: "user@example.com" }, }); fireEvent.change(screen.getByPlaceholderText("Password"), { target: { value: "password123" }, }); fireEvent.click(screen.getByRole("button", { name: /login/i }));
await waitFor(() => { expect(login).toHaveBeenCalledWith({ email: "user@example.com", password: "password123", }); expect(onSuccess).toHaveBeenCalled(); }); });
it("should display error message on login failure", async () => { const { login } = await import("./api"); login.mockRejectedValue(new Error("Invalid credentials"));
render(<LoginForm onSuccess={vi.fn()} />);
fireEvent.change(screen.getByPlaceholderText("Email"), { target: { value: "user@example.com" }, }); fireEvent.change(screen.getByPlaceholderText("Password"), { target: { value: "wrongpassword" }, }); fireEvent.click(screen.getByRole("button", { name: /login/i }));
await waitFor(() => { expect(screen.getByRole("alert")).toHaveTextContent( "Invalid credentials", ); }); });});End-to-End Testing: Testing User Flows
End-to-end (E2E) tests simulate real user interactions with your application. They test complete workflows from the user’s perspective, ensuring that all parts of your application work together correctly.
When to Use E2E Tests
✅ Critical user journeys: Login, checkout, account creation
✅ Cross-browser compatibility: Ensure features work in different browsers
✅ Regression testing: Prevent breaking existing functionality
✅ User workflows: Complete multi-step processes
❌ Don’t use for: Simple unit test scenarios, testing implementation details
E2E Testing with Playwright
Playwright is a modern E2E testing framework that supports multiple browsers and provides excellent debugging tools.
// E2E test: User login and profile update flowimport { test, expect } from "@playwright/test";
test.describe("User Authentication Flow", () => { test("should login and update profile", async ({ page }) => { // Navigate to login page await page.goto("https://example.com/login");
// Fill in login form await page.fill('input[type="email"]', "user@example.com"); await page.fill('input[type="password"]', "password123"); await page.click('button[type="submit"]');
// Wait for navigation to dashboard await page.waitForURL("**/dashboard"); await expect(page).toHaveURL(/dashboard/);
// Navigate to profile page await page.click('a[href="/profile"]'); await expect(page).toHaveURL(/profile/);
// Update profile information await page.fill('input[name="name"]', "John Updated"); await page.fill('input[name="bio"]', "Updated bio"); await page.click('button[type="submit"]');
// Verify success message await expect(page.locator(".success-message")).toBeVisible(); await expect(page.locator(".success-message")).toContainText( "Profile updated", ); });
test("should handle login failure gracefully", async ({ page }) => { await page.goto("https://example.com/login");
await page.fill('input[type="email"]', "wrong@example.com"); await page.fill('input[type="password"]', "wrongpassword"); await page.click('button[type="submit"]');
// Verify error message appears await expect(page.locator(".error-message")).toBeVisible(); await expect(page.locator(".error-message")).toContainText( "Invalid credentials", );
// Verify user is still on login page await expect(page).toHaveURL(/login/); });});E2E Testing Shopping Cart Flow
import { test, expect } from "@playwright/test";
test.describe("Shopping Cart Flow", () => { test("should add items to cart and complete checkout", async ({ page }) => { // Browse products await page.goto("https://example.com/products");
// Add first product to cart await page.click('[data-testid="product-1"] button'); await expect(page.locator('[data-testid="cart-count"]')).toHaveText("1");
// Add second product to cart await page.click('[data-testid="product-2"] button'); await expect(page.locator('[data-testid="cart-count"]')).toHaveText("2");
// Go to cart await page.click('[data-testid="cart-icon"]'); await expect(page).toHaveURL(/cart/);
// Verify items in cart const cartItems = page.locator('[data-testid="cart-item"]'); await expect(cartItems).toHaveCount(2);
// Proceed to checkout await page.click('button:has-text("Checkout")'); await expect(page).toHaveURL(/checkout/);
// Fill shipping information await page.fill('input[name="name"]', "John Doe"); await page.fill('input[name="address"]', "123 Main St"); await page.fill('input[name="city"]', "New York"); await page.selectOption('select[name="country"]', "US");
// Fill payment information await page.fill('input[name="cardNumber"]', "4111111111111111"); await page.fill('input[name="expiry"]', "12/25"); await page.fill('input[name="cvv"]', "123");
// Complete order await page.click('button:has-text("Place Order")');
// Verify order confirmation await expect(page).toHaveURL(/order-confirmation/); await expect(page.locator("h1")).toContainText("Order Confirmed"); });});E2E Testing with Page Object Model
The Page Object Model (POM) pattern helps organize E2E tests by encapsulating page interactions in reusable classes.
// Page Object: LoginPageclass LoginPage { constructor(page) { this.page = page; this.emailInput = page.locator('input[type="email"]'); this.passwordInput = page.locator('input[type="password"]'); this.submitButton = page.locator('button[type="submit"]'); this.errorMessage = page.locator(".error-message"); }
async goto() { await this.page.goto("https://example.com/login"); }
async login(email, password) { await this.emailInput.fill(email); await this.passwordInput.fill(password); await this.submitButton.click(); }
async getErrorMessage() { return await this.errorMessage.textContent(); }}
// Page Object: DashboardPageclass DashboardPage { constructor(page) { this.page = page; this.welcomeMessage = page.locator(".welcome-message"); this.profileLink = page.locator('a[href="/profile"]'); }
async isVisible() { await this.page.waitForURL("**/dashboard"); }
async goToProfile() { await this.profileLink.click(); }}
// E2E test using Page Objectsimport { test, expect } from "@playwright/test";import { LoginPage } from "./pages/LoginPage";import { DashboardPage } from "./pages/DashboardPage";
test("should login using page objects", async ({ page }) => { const loginPage = new LoginPage(page); const dashboardPage = new DashboardPage(page);
await loginPage.goto(); await loginPage.login("user@example.com", "password123"); await dashboardPage.isVisible();
expect(await dashboardPage.welcomeMessage.textContent()).toContain("Welcome");});💡 Pro Tip: Keep E2E tests focused on critical user journeys. Aim for 10-20 E2E tests that cover your most important workflows rather than trying to test everything.
Visual Testing: Ensuring UI Consistency
Visual testing (also called visual regression testing) captures screenshots of your UI and compares them against baseline images to detect unintended visual changes.
When to Use Visual Testing
✅ Design system components: Ensure components look consistent
✅ Responsive design: Verify layouts at different breakpoints
✅ Cross-browser testing: Detect browser-specific rendering issues
✅ Accessibility: Verify visual accessibility features
Visual Testing with Playwright
Playwright has built-in screenshot capabilities for visual testing.
import { test, expect } from "@playwright/test";
test.describe("Visual Regression Tests", () => { test("homepage should match baseline", async ({ page }) => { await page.goto("https://example.com"); await expect(page).toHaveScreenshot("homepage.png"); });
test("product page should match baseline", async ({ page }) => { await page.goto("https://example.com/products/1"); await expect(page).toHaveScreenshot("product-page.png"); });
test("mobile view should match baseline", async ({ page }) => { await page.setViewportSize({ width: 375, height: 667 }); // iPhone SE await page.goto("https://example.com"); await expect(page).toHaveScreenshot("homepage-mobile.png"); });});Visual Testing Component Library
For component-level visual testing, tools like Chromatic or Percy work well with Storybook.
// Storybook story for visual testingimport Button from "./Button";
export default { title: "Components/Button", component: Button,};
export const Primary = { args: { label: "Click me", variant: "primary", },};
export const Secondary = { args: { label: "Click me", variant: "secondary", },};
export const Disabled = { args: { label: "Click me", disabled: true, },};⚠️ Important: Visual tests can be flaky due to timing, fonts, or browser differences. Use them as a supplement to functional tests, not a replacement.
Choosing the Right Testing Tools
The JavaScript ecosystem offers many testing tools. Here’s a comparison to help you choose:
Unit Testing Frameworks
Jest:
- ✅ Most popular, extensive ecosystem
- ✅ Built-in mocking and assertions
- ✅ Great for React applications
- ❌ Can be slower than alternatives
- ❌ Requires more configuration
Vitest:
- ✅ Fast (uses Vite)
- ✅ Jest-compatible API
- ✅ Native ESM support
- ✅ Great TypeScript support
- ❌ Newer, smaller ecosystem
Mocha:
- ✅ Flexible and lightweight
- ✅ Works with any assertion library
- ❌ Requires more setup
- ❌ Less opinionated
E2E Testing Frameworks
Playwright:
- ✅ Fast and reliable
- ✅ Multiple browser support
- ✅ Great debugging tools
- ✅ Auto-waiting built-in
- ✅ Modern API
Cypress:
- ✅ Great developer experience
- ✅ Time-travel debugging
- ✅ Large community
- ❌ Limited cross-browser support
- ❌ Runs in browser (limitations)
Puppeteer:
- ✅ Direct Chrome DevTools Protocol access
- ✅ Good for automation
- ❌ Chrome/Chromium only
- ❌ Lower-level API
Testing Library Recommendations
React Testing Library:
- ✅ Encourages testing behavior, not implementation
- ✅ Accessible queries
- ✅ Works with any test runner
Testing Library:
- ✅ Framework-agnostic
- ✅ Consistent API across frameworks
- ✅ Focus on accessibility
Recommended Tool Stack
For a modern React/TypeScript application:
{ "devDependencies": { "vitest": "^1.0.0", "@testing-library/react": "^14.0.0", "@testing-library/jest-dom": "^6.0.0", "@testing-library/user-event": "^14.0.0", "@playwright/test": "^1.40.0", "jsdom": "^23.0.0" }}💡 Pro Tip: Start with Vitest for unit tests and Playwright for E2E tests. They’re modern, fast, and have excellent TypeScript support.
Testing Patterns and Best Practices
The AAA Pattern
Structure your tests using the Arrange-Act-Assert pattern:
describe("calculateTotal", () => { it("should calculate total with tax", () => { // Arrange: Set up test data const items = [{ price: 10 }, { price: 20 }]; const taxRate = 0.1;
// Act: Execute the function const result = calculateTotal(items, taxRate);
// Assert: Verify the result expect(result).toBe(33); });});Test Naming Conventions
Use descriptive test names that explain what is being tested:
// ✅ Good: Clear and descriptiveit("should return error when email is invalid", () => {});it("should increment counter when button is clicked", () => {});it("should display loading state while fetching data", () => {});
// ❌ Bad: Vague and unclearit("works", () => {});it("test1", () => {});it("should work correctly", () => {});One Assertion Per Test (When Possible)
While not always practical, try to test one thing per test:
// ✅ Good: Focused testsit("should return true for valid email", () => { expect(isValidEmail("user@example.com")).toBe(true);});
it("should return false for invalid email", () => { expect(isValidEmail("invalid")).toBe(false);});
// ❌ Less ideal: Multiple concernsit("should validate email", () => { expect(isValidEmail("user@example.com")).toBe(true); expect(isValidEmail("invalid")).toBe(false); expect(isValidEmail("")).toBe(false);});Test Independence
Each test should be independent and able to run in isolation:
// ❌ Bad: Tests depend on each otherlet counter = 0;
it("should increment counter", () => { counter++; expect(counter).toBe(1);});
it("should increment counter again", () => { counter++; // Depends on previous test expect(counter).toBe(2);});
// ✅ Good: Tests are independentit("should increment counter from 0", () => { const counter = 0; const result = increment(counter); expect(result).toBe(1);});
it("should increment counter from 5", () => { const counter = 5; const result = increment(counter); expect(result).toBe(6);});Testing Edge Cases
Always test edge cases and boundary conditions:
describe("divide", () => { it("should divide two positive numbers", () => { expect(divide(10, 2)).toBe(5); });
it("should handle division by zero", () => { expect(() => divide(10, 0)).toThrow("Cannot divide by zero"); });
it("should handle negative numbers", () => { expect(divide(-10, 2)).toBe(-5); });
it("should handle decimal results", () => { expect(divide(1, 3)).toBeCloseTo(0.333, 3); });});Test Organization and Structure
File Structure
Organize tests to mirror your source code structure:
src/ components/ Button/ Button.tsx Button.test.tsx Form/ Form.tsx Form.test.tsx utils/ formatCurrency.ts formatCurrency.test.ts hooks/ useDebounce.ts useDebounce.test.tsGrouping Related Tests
Use describe blocks to group related tests:
describe("UserService", () => { describe("createUser", () => { it("should create user with valid data", () => {}); it("should throw error for invalid email", () => {}); it("should throw error for missing name", () => {}); });
describe("updateUser", () => { it("should update user fields", () => {}); it("should throw error for non-existent user", () => {}); });
describe("deleteUser", () => { it("should delete user by id", () => {}); it("should throw error for non-existent user", () => {}); });});Test Setup and Teardown
Use beforeEach, afterEach, beforeAll, and afterAll for setup and cleanup:
describe("DatabaseService", () => { let db;
beforeAll(async () => { // Set up test database once db = await createTestDatabase(); });
beforeEach(async () => { // Clean database before each test await db.clear(); });
afterAll(async () => { // Clean up test database await db.destroy(); });
it("should insert user", async () => { const user = await db.insertUser({ name: "John" }); expect(user.id).toBeDefined(); });});Mocking and Test Doubles
Mocking allows you to isolate units under test by replacing dependencies with controlled alternatives.
Types of Test Doubles
Stub: Provides predefined responses to method calls
Mock: Records interactions and verifies they occurred
Spy: Wraps real object and records interactions
Fake: Working implementation with simplified behavior
Mocking Functions
import { vi } from "vitest";
// Mock a functionconst fetchUser = vi.fn();
// Set return valuefetchUser.mockReturnValue({ id: 1, name: "John" });
// Set implementationfetchUser.mockImplementation(async (id) => { return { id, name: "John" };});
// Verify callsexpect(fetchUser).toHaveBeenCalledWith(1);expect(fetchUser).toHaveBeenCalledTimes(1);Mocking Modules
// Mock an entire modulevi.mock("./api", () => ({ fetchUser: vi.fn(), createUser: vi.fn(), updateUser: vi.fn(),}));
// Mock with implementationvi.mock("./api", async () => { const actual = await vi.importActual("./api"); return { ...actual, fetchUser: vi.fn().mockResolvedValue({ id: 1, name: "John" }), };});Mocking HTTP Requests
import { vi } from "vitest";import { rest } from "msw";import { setupServer } from "msw/node";
// Set up MSW serverconst server = setupServer( rest.get("/api/user/:id", (req, res, ctx) => { return res(ctx.json({ id: req.params.id, name: "John" })); }),);
beforeAll(() => server.listen());afterEach(() => server.resetHandlers());afterAll(() => server.close());
// Testit("should fetch user data", async () => { const user = await fetchUser("1"); expect(user.name).toBe("John");});Mocking React Components
// Mock a componentvi.mock("./ExpensiveComponent", () => ({ default: vi.fn(() => <div>Mocked Component</div>),}));
// Mock with propsvi.mock("./ExpensiveComponent", () => ({ default: ({ children }) => <div data-testid="mocked">{children}</div>,}));💡 Pro Tip: Mock at the boundaries (API calls, external services) rather than internal implementation details. This makes tests more resilient to refactoring.
Testing Asynchronous Code
Asynchronous code requires special handling in tests. Understanding how to test promises, async/await, and timers is crucial.
Testing Promises
// Function that returns a promiseasync function fetchData(url) { const response = await fetch(url); return response.json();}
// Test with async/awaitit("should fetch data", async () => { const data = await fetchData("https://api.example.com/data"); expect(data).toEqual({ id: 1, name: "Test" });});
// Test promise rejectionit("should handle fetch errors", async () => { await expect(fetchData("invalid-url")).rejects.toThrow();});Testing Async Functions with waitFor
React Testing Library’s waitFor helps test asynchronous UI updates:
import { waitFor } from "@testing-library/react";
it("should display data after loading", async () => { render(<DataComponent />);
// Wait for loading to disappear await waitFor(() => { expect(screen.queryByText("Loading...")).not.toBeInTheDocument(); });
// Verify data is displayed expect(screen.getByText("Data loaded")).toBeInTheDocument();});Testing Timers
import { vi, beforeEach, afterEach } from "vitest";
describe("Timer functions", () => { beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.restoreAllMocks(); });
it("should call callback after delay", () => { const callback = vi.fn();
setTimeout(callback, 1000); expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000); expect(callback).toHaveBeenCalledTimes(1); });
it("should handle intervals", () => { const callback = vi.fn();
setInterval(callback, 100);
vi.advanceTimersByTime(300); expect(callback).toHaveBeenCalledTimes(3); });});For more details on testing async code, check out our guide on JavaScript Promises and Async/Await.
Performance Testing
Performance testing ensures your application meets performance requirements and identifies bottlenecks.
Measuring Component Render Performance
import { render } from "@testing-library/react";import { performance } from "perf_hooks";
it("should render large list efficiently", () => { const start = performance.now(); render(<LargeList items={generateItems(1000)} />); const end = performance.now();
const renderTime = end - start; expect(renderTime).toBeLessThan(100); // Should render in under 100ms});Load Testing with Playwright
import { test, expect } from "@playwright/test";
test("should handle concurrent users", async ({ page }) => { const startTime = Date.now();
await page.goto("https://example.com"); await page.click('button:has-text("Load Data")'); await page.waitForSelector(".data-loaded");
const loadTime = Date.now() - startTime; expect(loadTime).toBeLessThan(2000); // Should load in under 2 seconds});Memory Leak Testing
it("should not leak memory", async () => { const initialMemory = process.memoryUsage().heapUsed;
// Perform operations that might cause leaks for (let i = 0; i < 100; i++) { render(<Component />); cleanup(); }
// Force garbage collection if available if (global.gc) { global.gc(); }
const finalMemory = process.memoryUsage().heapUsed; const memoryIncrease = finalMemory - initialMemory;
// Memory increase should be reasonable expect(memoryIncrease).toBeLessThan(10 * 1024 * 1024); // Less than 10MB});Continuous Integration and Testing
Integrating tests into your CI/CD pipeline ensures code quality and catches issues before they reach production.
GitHub Actions Example
name: Tests
on: push: branches: [main, develop] pull_request: branches: [main, develop]
jobs: test: runs-on: ubuntu-latest
strategy: matrix: node-version: [18.x, 20.x]
steps: - uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} cache: "pnpm"
- name: Install pnpm uses: pnpm/action-setup@v2 with: version: 8
- name: Install dependencies run: pnpm install
- name: Run unit tests run: pnpm test:unit
- name: Run E2E tests run: pnpm test:e2e
- name: Upload test results uses: actions/upload-artifact@v3 if: always() with: name: test-results path: test-results/Test Scripts in package.json
{ "scripts": { "test": "vitest", "test:unit": "vitest run", "test:e2e": "playwright test", "test:coverage": "vitest run --coverage", "test:watch": "vitest watch", "test:ui": "vitest --ui" }}Code Coverage
Aim for meaningful coverage, not just high percentages:
import { defineConfig } from "vitest/config";
export default defineConfig({ test: { coverage: { provider: "v8", reporter: ["text", "json", "html"], exclude: ["node_modules/", "src/**/*.test.ts", "src/**/*.test.tsx"], thresholds: { lines: 80, functions: 80, branches: 75, statements: 80, }, }, },});💡 Pro Tip: Focus on testing critical paths and business logic rather than achieving 100% coverage. High coverage doesn’t guarantee quality tests.
Common Testing Pitfalls and How to Avoid Them
Pitfall 1: Testing Implementation Details
❌ Bad: Testing internal state or implementation
// ❌ Testing implementation detailsit("should set isLoading to true", () => { const component = render(<DataComponent />); expect(component.instance().state.isLoading).toBe(true);});✅ Good: Testing behavior from user’s perspective
// ✅ Testing behaviorit("should show loading spinner while fetching data", () => { render(<DataComponent />); expect(screen.getByText("Loading...")).toBeInTheDocument();});Pitfall 2: Over-Mocking
❌ Bad: Mocking everything
// ❌ Over-mockingvi.mock("./utils");vi.mock("./hooks");vi.mock("./components");// ... everything is mocked✅ Good: Mock only external dependencies
// ✅ Mock only what's necessaryvi.mock("./api"); // External API calls// Use real implementations for internal codePitfall 3: Brittle Tests
❌ Bad: Tests that break with minor changes
// ❌ Brittle: Depends on specific HTML structureexpect(container.querySelector("div > div > button")).toBeInTheDocument();✅ Good: Tests that focus on behavior
// ✅ Resilient: Uses semantic queriesexpect(screen.getByRole("button", { name: /submit/i })).toBeInTheDocument();Pitfall 4: Not Testing Error Cases
❌ Bad: Only testing happy paths
// ❌ Missing error casesit("should login user", () => { // Only tests success case});✅ Good: Testing both success and failure
// ✅ Comprehensiveit("should login user with valid credentials", () => {});it("should show error with invalid credentials", () => {});it("should handle network errors", () => {});Pitfall 5: Slow Test Suites
❌ Bad: Making unit tests slow
// ❌ Slow: Real API calls in unit testsit("should fetch data", async () => { const data = await realApiCall(); // Slow!});✅ Good: Fast, isolated tests
// ✅ Fast: Mocked dependenciesit("should fetch data", async () => { const data = await mockedApiCall(); // Fast!});Pitfall 6: Flaky Tests
❌ Bad: Tests that sometimes pass, sometimes fail
// ❌ Flaky: No waiting for async operationsit("should display data", () => { render(<DataComponent />); expect(screen.getByText("Data")).toBeInTheDocument(); // Might not be loaded yet});✅ Good: Reliable tests with proper waiting
// ✅ Reliable: Waits for async operationsit("should display data", async () => { render(<DataComponent />); await waitFor(() => { expect(screen.getByText("Data")).toBeInTheDocument(); });});For more on avoiding React-specific pitfalls, see our guide on 10 Most Common Pitfalls in React.js.
Conclusion
Testing is an essential part of building reliable, maintainable web applications. By understanding the testing pyramid and implementing a balanced mix of unit, integration, and E2E tests, you can catch bugs early, refactor with confidence, and deliver high-quality software.
Remember these key takeaways:
- Start with unit tests: They’re fast, cheap, and catch most bugs
- Add integration tests: Verify components work together correctly
- Use E2E tests sparingly: Focus on critical user journeys
- Test behavior, not implementation: Your tests will be more resilient
- Keep tests fast: Slow tests slow down development
- Mock at boundaries: Isolate external dependencies
- Test edge cases: Don’t just test happy paths
The tools and patterns covered in this guide—Jest, Vitest, Playwright, React Testing Library—provide a solid foundation for testing modern web applications. Start with the basics, gradually add more sophisticated tests, and remember that good tests are maintainable, readable, and provide value.
As you continue building your test suite, refer to testing tool documentation like the Jest cheatsheet and Vitest cheatsheet for quick reference. Keep iterating on your testing strategy, and don’t be afraid to refactor tests as your application evolves.
Happy testing! 🧪