As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!
Testing modern web applications built with components requires a thoughtful approach. It's not just about checking if code runs. It's about making sure the pieces fit together, look right, and work well for everyone. I want to share strategies that have helped me build confidence in my work, moving from simple checks to a complete safety net.
The foundation starts with testing individual components in isolation. Think of a ProductCard that shows an item's name, price, and an "Add to Cart" button. The goal here is to verify it renders correctly and responds to user actions as expected. We mock its dependencies, like the function that handles the cart update, to test the component's own logic.
// A test for a React ProductCard component
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ProductCard } from './ProductCard';
describe('ProductCard Component', () => {
const sampleProduct = {
id: 'prod_abc123',
name: 'Coffee Mug',
price: 18.50,
imageUrl: '/mug.jpg',
isAvailable: true
};
const mockAddToCart = jest.fn(); // A fake function to track calls
it('shows the product name and price on screen', () => {
render(<ProductCard product={sampleProduct} onAddToCart={mockAddToCart} />);
// These assertions are like asking: "Is this text visible?"
expect(screen.getByText('Coffee Mug')).toBeInTheDocument();
expect(screen.getByText('$18.50')).toBeInTheDocument();
expect(screen.getByAltText('Coffee Mug')).toBeInTheDocument();
});
it('lets a user click the "Add to Cart" button', async () => {
const user = userEvent.setup();
render(<ProductCard product={sampleProduct} onAddToCart={mockAddToCart} />);
const button = screen.getByRole('button', { name: /add to cart/i });
await user.click(button);
// Did our mock function get called with the right product ID?
expect(mockAddToCart).toHaveBeenCalledWith('prod_abc123');
});
it('disables the button if the product is sold out', () => {
const unavailableProduct = { ...sampleProduct, isAvailable: false };
render(<ProductCard product={unavailableProduct} onAddToCart={mockAddToCart} />);
const button = screen.getByRole('button');
expect(button).toBeDisabled();
expect(screen.getByText('Sold Out')).toBeInTheDocument();
});
});
These tests are fast and focused. They answer basic questions: does it show the right data? Do the buttons work? I run them constantly while building a component. However, components don't live alone. They connect to other components, to data, and to the user's journey. This is where integration testing comes in.
Integration tests check how components work together. Imagine testing a shopping flow: viewing a list, adding an item, and seeing the cart update. This tests the connections and shared state.
// Testing the connection between a product list and a shopping cart
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CartProvider } from '../context/CartContext';
import { ProductList } from './ProductList';
import { CartSummary } from './CartSummary';
describe('Shopping Cart Integration', () => {
it('allows a user to add a product and see it in the cart', async () => {
const user = userEvent.setup();
// Render the provider and both components together
render(
<CartProvider>
<ProductList />
<CartSummary />
</CartProvider>
);
// Wait for the fake products to load
await waitFor(() => {
expect(screen.getByText('Coffee Mug')).toBeInTheDocument();
});
// Find and click the first "Add to Cart" button
const addButton = screen.getAllByRole('button', { name: /add to cart/i })[0];
await user.click(addButton);
// The cart summary should now show 1 item
await waitFor(() => {
expect(screen.getByText('Items in cart: 1')).toBeInTheDocument();
});
// Open the cart drawer to see details
const cartToggle = screen.getByLabelText('Show cart');
await user.click(cartToggle);
// Verify the specific item is now listed inside the cart
await waitFor(() => {
expect(screen.getByText('Coffee Mug')).toBeInTheDocument();
expect(screen.getByText('$18.50')).toBeInTheDocument();
});
});
});
This test is more realistic. It uses the actual CartProvider that manages state. It mimics a real user's action sequence. The key tool here is waitFor, which tells the test to pause and keep checking until an expected condition is met, like the cart updating. This is crucial for testing asynchronous behavior, like data fetching or state updates.
Sometimes, code works perfectly but the appearance breaks. A margin changes, a color flips, or a layout shifts on a certain screen size. Traditional tests won't catch this because they only check the logic, not the pixels. This is the purpose of visual regression testing.
Visual tests take screenshots of your components or pages and compare them to a baseline. If something looks different, the test fails. It catches what other tests miss.
// Using Playwright to capture and compare visual states
import { test, expect } from '@playwright/test';
import percySnapshot from '@percy/playwright'; // A visual testing service
test.describe('Appearance Tests', () => {
test('main product page looks correct', async ({ page }) => {
await page.goto('http://localhost:3000/products');
// First, a basic check that content loaded
await expect(page.getByRole('heading', { name: 'Our Products' })).toBeVisible();
// Then, capture a visual snapshot for comparison
await percySnapshot(page, 'Product Listing Page');
});
test('component looks right in different states', async ({ page }) => {
await page.goto('http://localhost:3000');
// Test a button's hover state
const button = page.getByRole('button', { name: 'Subscribe' });
await button.hover();
await page.waitForTimeout(100); // Let any CSS transition finish
await percySnapshot(page, 'Button - Hover State');
// Test on a mobile-sized screen
await page.setViewportSize({ width: 375, height: 667 });
await percySnapshot(page, 'Homepage - Mobile View');
});
});
When you run this for the first time, it saves a "baseline" image. On subsequent runs, it takes a new screenshot and compares it pixel-by-pixel to the baseline. If there's an unexpected difference, you get a report showing exactly what changed. I use this for UI libraries and design systems to ensure a consistent look.
Performance is a feature. A slow, janky interface frustrates users. Performance testing for components involves measuring how long they take to render and how they behave under stress.
// Measuring rendering performance with React's Profiler
import { Profiler } from 'react';
import { render } from '@testing-library/react';
import { ProductGrid } from './ProductGrid';
it('renders a large grid within a time limit', () => {
const performanceReport = jest.fn();
const manyProducts = Array.from({ length: 500 }, (_, index) => ({
id: `item-${index}`,
name: `Item ${index}`,
price: index * 2
}));
render(
<Profiler id="ProductGrid" onRender={performanceReport}>
<ProductGrid products={manyProducts} />
</Profiler>
);
// The Profiler calls our function with timing data
const call = performanceReport.mock.calls[0];
const componentId = call[0];
const renderTime = call[3]; // Actual duration in milliseconds
expect(componentId).toBe('ProductGrid');
expect(renderTime).toBeLessThan(150); // Our performance budget: 150ms
});
This is a proactive check. If a teammate adds a complex animation or an inefficient data calculation that makes rendering take 300ms, this test will fail. It forces the team to consider performance during development, not as an afterthought.
Accessibility testing ensures everyone can use your application, including people who rely on screen readers, keyboard navigation, or other assistive technologies. It's both an ethical responsibility and a legal requirement in many cases.
// Checking for accessibility issues with jest-axe
import { axe, toHaveNoViolations } from 'jest-axe';
import { render } from '@testing-library/react';
import { LoginForm } from './LoginForm';
expect.extend(toHaveNoViolations); // Add a custom matcher
describe('LoginForm Accessibility', () => {
it('has no detectable accessibility violations', async () => {
const { container } = render(<LoginForm />);
const results = await axe(container);
expect(results).toHaveNoViolations(); // This will fail if issues are found
});
it('can be operated with a keyboard alone', async () => {
const user = userEvent.setup();
render(<LoginForm />);
// Tab to the email input
await user.tab();
expect(screen.getByLabelText(/email address/i)).toHaveFocus();
// Tab to the password input
await user.tab();
expect(screen.getByLabelText(/password/i)).toHaveFocus();
// Tab to the submit button and press Enter
await user.tab();
expect(screen.getByRole('button', { name: /sign in/i })).toHaveFocus();
await user.keyboard('{Enter}');
// ... then check that the submit logic ran
});
});
The axe engine checks against the WCAG (Web Content Accessibility Guidelines) rules. It catches common problems like missing image alt text, poor color contrast, or improper ARIA attributes. I run these tests alongside all others; they're non-negotiable for quality.
Modern apps talk to servers. Testing these interactions used to be messy, requiring live APIs or complex fake servers. Now, Mock Service Worker (MSW) intercepts network requests at the browser level, letting you simulate any API scenario.
// Setting up a mock server to handle API calls in tests
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from './UserProfile';
// Define what requests to intercept and how to respond
const mockServer = setupServer(
http.get('/api/user', () => {
return HttpResponse.json({
id: 'user_123',
name: 'Alex Johnson',
email: 'alex@example.com'
});
}),
http.get('/api/user/orders', () => {
return HttpResponse.json({
orders: [
{ id: 'order_1', total: 49.99 },
{ id: 'order_2', total: 22.50 }
]
});
})
);
describe('UserProfile with API', () => {
beforeAll(() => mockServer.listen()); // Start intercepting
afterEach(() => mockServer.resetHandlers());
afterAll(() => mockServer.close()); // Stop intercepting
it('loads and displays user data from the API', async () => {
render(<UserProfile />);
// The component should show a loading state first
expect(screen.getByText('Loading...')).toBeInTheDocument();
// Then show the user's name once the fake API responds
await waitFor(() => {
expect(screen.getByText('Alex Johnson')).toBeInTheDocument();
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
});
it('shows an error message if the API fails', async () => {
// Override the default handler just for this test
mockServer.use(
http.get('/api/user', () => {
return new HttpResponse(null, { status: 500 })
})
);
render(<UserProfile />);
await waitFor(() => {
expect(screen.getByText('Could not load profile')).toBeInTheDocument();
});
});
});
This is powerful. You can test loading states, error handling, and success states without a real backend. You can even simulate slow networks by adding a delay to the response handler.
Finally, snapshot testing is a quick, low-effort way to guard against unexpected changes to your component's structure. It saves a "snapshot" of your rendered component's HTML. On future test runs, it compares the new output to the saved snapshot. If they differ, the test fails, and you must decide if the change was intentional.
// Basic snapshot test with Jest
import { render } from '@testing-library/react';
import { Banner } from './Banner';
describe('Banner Snapshots', () => {
it('looks the same as last time', () => {
const { container } = render(<Banner title="Welcome" />);
expect(container.firstChild).toMatchSnapshot();
});
});
The first time you run this, Jest creates a __snapshots__ folder with a file containing the HTML. If you later change the Banner component, the test will fail and show you the difference. You then update the snapshot if the change was correct. I use these sparingly, mainly for small, stable components like headers, buttons, or icons, where the structure shouldn't change often.
Putting it all together, the strategy is layered, like a safety net. Fast, isolated component tests form the tightest mesh. Integration tests check the connections between those pieces. Visual tests guard the look and feel. Performance tests ensure speed. Accessibility tests guarantee usability. API tests validate data flow. Snapshot tests catch stray structural changes.
This approach shifts effort earlier in the development cycle. You find problems while writing code, not weeks later during a manual QA cycle or, worse, after a user reports a bug. It gives a team the confidence to refactor and improve code, knowing that if they break something, the tests will likely catch it. It turns testing from a chore into the backbone of reliable, maintainable software.
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva
Top comments (0)