Learn practical testing strategies that will help you build more reliable applications and catch bugs before your users do.
Testing Your Code: A Beginner's Guide to Reliable Applications
Testing might seem like extra work, but it's actually your safety net. Good tests catch bugs before they reach users, make refactoring safer, and help you sleep better at night knowing your code works as expected.
Why Testing Matters
Imagine shipping a feature and discovering it breaks the login system. Testing prevents these nightmares by catching issues early. Think of tests as your code's health checkup—they tell you when something's wrong before it becomes a bigger problem.
The Testing Pyramid: Your Strategy Guide
The testing pyramid is a simple concept that guides how much of each type of test you should write:
Unit Tests (70% of your tests)
These test individual functions in isolation. They're fast, easy to write, and catch most bugs.
// Testing a simple function
function calculateTotal(price, tax) {
return price + price * tax;
}
test("calculates total with tax correctly", () => {
expect(calculateTotal(100, 0.1)).toBe(110);
});
Integration Tests (20% of your tests)
These test how different parts of your app work together.
// Testing that your API and database work together
test("creates a new user", async () => {
const userData = { name: "John", email: "john@example.com" };
const user = await createUser(userData);
expect(user.id).toBeDefined();
expect(user.name).toBe("John");
});
End-to-End Tests (10% of your tests)
These test complete user workflows, like signing up or making a purchase.
Getting Started with Unit Testing
Unit tests are your bread and butter. They're quick to write and run, making them perfect for catching bugs early.
Testing Pure Functions
Start with functions that don't depend on external things:
function formatCurrency(amount) {
return `$${amount.toFixed(2)}`;
}
test("formats currency correctly", () => {
expect(formatCurrency(10)).toBe("$10.00");
expect(formatCurrency(10.5)).toBe("$10.50");
});
Testing with Different Inputs
Good tests check various scenarios:
function validateEmail(email) {
return email.includes("@") && email.includes(".");
}
test("validates emails correctly", () => {
expect(validateEmail("user@example.com")).toBe(true);
expect(validateEmail("invalid-email")).toBe(false);
expect(validateEmail("")).toBe(false);
});
Testing React Components
Testing components doesn't have to be complicated. Focus on what users see and do:
import { render, screen, fireEvent } from "@testing-library/react";
function LoginForm({ onSubmit }) {
const [email, setEmail] = useState("");
return (
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit(email);
}}
>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
/>
<button type="submit">Login</button>
</form>
);
}
test("submits email when form is submitted", () => {
const mockSubmit = jest.fn();
render(<LoginForm onSubmit={mockSubmit} />);
const emailInput = screen.getByPlaceholderText("Enter your email");
const submitButton = screen.getByText("Login");
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
fireEvent.click(submitButton);
expect(mockSubmit).toHaveBeenCalledWith("test@example.com");
});
Testing Async Code
Modern apps do a lot of async work. Here's how to test it:
// Testing a function that fetches data
async function fetchUserName(userId) {
const response = await fetch(`/api/users/${userId}`);
const user = await response.json();
return user.name;
}
test("fetches user name", async () => {
// Mock the fetch function
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ name: "John Doe" }),
}),
);
const name = await fetchUserName(1);
expect(name).toBe("John Doe");
});
Integration Testing Made Simple
Integration tests check that different parts of your app work together correctly.
Testing API Endpoints
test("POST /users creates a new user", async () => {
const userData = { name: "Jane", email: "jane@example.com" };
const response = await request(app).post("/users").send(userData).expect(201);
expect(response.body.name).toBe("Jane");
expect(response.body.email).toBe("jane@example.com");
});
Testing Database Operations
test("saves user to database", async () => {
const userData = { name: "Bob", email: "bob@example.com" };
const user = await userService.create(userData);
const savedUser = await userService.findById(user.id);
expect(savedUser.name).toBe("Bob");
});
End-to-End Testing
E2E tests simulate real user interactions. They're slower but catch issues other tests miss.
test("user can complete signup process", async () => {
await page.goto("/signup");
// Fill out the form
await page.fill('[data-testid="email"]', "newuser@example.com");
await page.fill('[data-testid="password"]', "securepassword");
await page.click('[data-testid="signup-button"]');
// Check that signup was successful
await expect(page.locator('[data-testid="welcome-message"]')).toBeVisible();
});
Test-Driven Development (TDD)
TDD is a technique where you write tests before writing code. It sounds backwards, but it works:
- Red: Write a failing test
- Green: Write minimal code to make it pass
- Refactor: Clean up the code
// Step 1: Write a failing test
test("calculates shopping cart total", () => {
const cart = new ShoppingCart();
cart.addItem({ price: 10, quantity: 2 });
cart.addItem({ price: 5, quantity: 1 });
expect(cart.getTotal()).toBe(25);
});
// Step 2: Write minimal code to pass
class ShoppingCart {
constructor() {
this.items = [];
}
addItem(item) {
this.items.push(item);
}
getTotal() {
return this.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
);
}
}
Common Testing Patterns
Mocking External Dependencies
When testing, you often need to fake external services:
// Mock an API call
jest.mock("./api", () => ({
fetchUser: jest.fn(() => Promise.resolve({ name: "Test User" })),
}));
test("displays user name", async () => {
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText("Test User")).toBeInTheDocument();
});
});
Testing Error Scenarios
Don't just test the happy path:
test("handles API errors gracefully", async () => {
// Mock a failed API call
global.fetch = jest.fn(() => Promise.reject(new Error("Network error")));
render(<UserList />);
await waitFor(() => {
expect(screen.getByText("Failed to load users")).toBeInTheDocument();
});
});
Setting Up Your Testing Environment
Basic Jest Configuration
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"jest": {
"testEnvironment": "jsdom",
"setupFilesAfterEnv": ["<rootDir>/src/setupTests.js"]
}
}
Essential Testing Libraries
- Jest: Test runner and assertion library
- React Testing Library: For testing React components
- Playwright: For end-to-end testing
- MSW: For mocking API calls
Best Practices for Better Tests
Write Descriptive Test Names
// Bad
test("user test", () => {
/* ... */
});
// Good
test("displays error message when login fails", () => {
/* ... */
});
Keep Tests Simple and Focused
Each test should check one thing:
// Test one behavior at a time
test("adds item to cart", () => {
const cart = new ShoppingCart();
cart.addItem({ name: "Book", price: 20 });
expect(cart.items).toHaveLength(1);
});
test("calculates cart total", () => {
const cart = new ShoppingCart();
cart.addItem({ name: "Book", price: 20 });
expect(cart.getTotal()).toBe(20);
});
Use the AAA Pattern
Structure your tests with Arrange, Act, Assert:
test("updates user profile", () => {
// Arrange
const user = new User({ name: "John" });
// Act
user.updateName("Jane");
// Assert
expect(user.name).toBe("Jane");
});
Testing in CI/CD
Automate your tests to run on every code change:
# GitHub Actions example
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: "18"
- run: npm install
- run: npm test
Common Testing Mistakes to Avoid
Testing Implementation Details
Focus on what users see, not how code works internally:
// Bad - testing internal state
expect(component.state.isLoading).toBe(true);
// Good - testing user experience
expect(screen.getByText("Loading...")).toBeInTheDocument();
Writing Tests That Are Too Complex
If your test is hard to understand, it's probably testing too much:
// Keep tests simple and focused
test("shows welcome message after login", () => {
render(<App />);
fireEvent.click(screen.getByText("Login"));
expect(screen.getByText("Welcome!")).toBeInTheDocument();
});
Not Testing Edge Cases
Test the unusual scenarios:
test("handles empty shopping cart", () => {
const cart = new ShoppingCart();
expect(cart.getTotal()).toBe(0);
});
test("handles negative quantities", () => {
const cart = new ShoppingCart();
expect(() => cart.addItem({ price: 10, quantity: -1 })).toThrow(
"Quantity must be positive",
);
});
Performance Testing Basics
Sometimes you need to test that your code is fast enough:
test("search completes within reasonable time", async () => {
const startTime = Date.now();
await searchUsers("john");
const duration = Date.now() - startTime;
expect(duration).toBeLessThan(1000); // Should complete in under 1 second
});
Building a Testing Culture
Start Small
Don't try to test everything at once. Start with:
- Critical business logic
- Functions that have caused bugs before
- New features as you build them
Make Tests Part of Your Workflow
- Write tests for new features
- Add tests when fixing bugs
- Review test coverage regularly
Learn from Failures
When tests fail, they're telling you something important. Don't just make them pass—understand why they failed.
Conclusion
Testing isn't about writing perfect code—it's about building confidence in your applications. Start with simple unit tests, gradually add integration tests, and use end-to-end tests for critical user journeys.
Remember: good tests are like good documentation. They explain what your code should do and help you catch problems early. The time you invest in testing pays off in fewer bugs, easier refactoring, and better sleep.
Start testing today, even if it's just one simple function. Your future self will thank you when you can refactor with confidence and deploy without fear.