Unit Tests: Your Code's Best Friend

Think of a trapeze artist. Would they perform without a safety net? Probably not. Unit tests are the safety net for developers. They give you the confidence to make changes, refactor, and add new features without worrying that you're secretly breaking something.

But good tests do more than just catch bugs. They are a form of living documentation that describes how your code is supposed to behave, and they can even help you write better-designed, more decoupled code.

What Does a Good Test Look Like?

A clean test is focused, readable, and structured. A great way to structure your tests is by following the Arrange-Act-Assert pattern.

  • Arrange: Set up the world for your test. Create any objects, variables, or mock dependencies that are needed.
  • Act: Execute the code you're actually testing. This is usually a single function call.
  • Assert: Check that the result is what you expected.

Let's look at a practical example. Here's a simple service we want to test:

// The code we want to test: src/services/discount.ts
export class DiscountService {
  getDiscount(customerAge: number, purchaseAmount: number): number {
    if (customerAge > 65) {
      return purchaseAmount * 0.15; // 15% discount for seniors
    }
    if (purchaseAmount > 100) {
      return purchaseAmount * 0.1; // 10% for large purchases
    }
    return 0;
  }
}

Now, let's write a test for it using the Arrange-Act-Assert pattern.

// The test: src/services/discount.test.ts
import { DiscountService } from './discount';

describe('DiscountService', () => {

  it('should give a 15% discount to seniors (over 65)', () => {
    // Arrange
    const discountService = new DiscountService();
    const customerAge = 70;
    const purchaseAmount = 50;

    // Act
    const discount = discountService.getDiscount(customerAge, purchaseAmount);

    // Assert
    expect(discount).toBe(7.5); // 15% of 50
  });

  it('should give a 10% discount for purchases over 100', () => {
    // Arrange
    const discountService = new DiscountService();
    const customerAge = 40;
    const purchaseAmount = 120;

    // Act
    const discount = discountService.getDiscount(customerAge, purchaseAmount);

    // Assert
    expect(discount).toBe(12); // 10% of 120
  });

  it('should give no discount for a standard customer with a small purchase', () => {
    // Arrange
    const discountService = new DiscountService();
    const customerAge = 30;
    const purchaseAmount = 50;
    
    // Act
    const discount = discountService.getDiscount(customerAge, purchaseAmount);
    
    // Assert
    expect(discount).toBe(0);
  });
});

Notice how clear the tests are. The it description explains the exact business rule being tested, and the AAA structure makes the test logic itself easy to follow.

Isolating Your Code with Mocks

Unit tests should test one thing in isolation. If the DiscountService depended on another service (like a DateService to check for birthday discounts), we wouldn't want to use the real DateService in our test. Why? Because if the DateService has a bug, our DiscountService test might fail, even if DiscountService itself is correct.

This is where mocks come in. We create a fake version of the dependency that we can control.

// Suppose our service now depends on a DateService
export class DateService {
  isBirthday(): boolean {
    // ... complex logic to check today's date ...
    return new Date().getMonth() === 11 && new Date().getDate() === 25;
  }
}

// We can mock it in our test to control its behavior
it('should give a 20% discount on the customer birthday', () => {
  // Arrange
  const mockDateService = {
    isBirthday: () => true, // We force it to return true for this test
  };
  const discountService = new DiscountService(mockDateService); // Inject the mock

  // Act
  const discount = discountService.getBirthdayDiscount(100);

  // Assert
  expect(discount).toBe(20);
});

What Makes a GREAT Test? (The FIRST principles)

  • Fast: Tests should run quickly. Slow tests get ignored.
  • Isolated/Independent: Tests should not depend on each other. You should be able to run any test in any order.
  • Repeatable: A test should produce the same result every time, regardless of the environment (e.g., database state, time of day). This is why we use mocks!
  • Self-Validating: The test should be able to determine if it passed or failed on its own. It should end with a clear expect.
  • Timely: Write tests before or during development, not weeks later. This is the idea behind Test-Driven Development (TDD).

Writing clean tests is just as important as writing clean code. They are your first line of defense against bugs and your best tool for building maintainable, long-lasting software.