A
Aghyad Alghazawi
1min read

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:

  1. Red: Write a failing test
  2. Green: Write minimal code to make it pass
  3. 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.