Writing Testable Code: A Guide to Better Software Testing

Common Misconceptions About Test Code

We’re too busy developing, when do we have time to write test code?

This premise is incorrect.

In the article To Student Developers Dreaming of Becoming Backend Developers, you can find the following statement:

“Saying ‘I didn’t have time to write tests’ is equivalent to saying ‘It takes me a long time to write test code.’”

“As you write test code, you’ll encounter tests that actually help you complete your immediate tasks faster. When developing complex interconnected programs, testing parts separately before manual testing through the final UI helps catch bugs more easily and quickly.”

Let’s compare development approaches with and without test code:

Therefore, “We write test code to develop faster” is closer to the truth.

So why do we feel that “development efficiency decreases” when we write tests?

‘Being busy’ means ‘it takes a long time to write test code,’ which indicates not that development takes longer with test code, but that our proficiency in writing test code is low.

Of course, frontend testing is challenging, so we often focus on how to create more pure functions effectively 🙂

We’re not losing development productivity by writing test code; in fact, we can gain:

  • Tests that help us complete our goals faster
  • Tests that improve productivity during maintenance
  • Tests that help with code changes and bug detection

So, should we just improve our proficiency with testing frameworks?

No.

Test code writing remains difficult even with high testing framework proficiency. This is because the existing code was difficult to test.

What makes code difficult to test? Code with tight coupling, low cohesion, insufficient abstraction, and dependencies.

What we need to learn is ‘how to write easily testable code.’

If code is easy to test, we can minimize the need for mock libraries.

Is “minimizing the use of mock libraries” good?

There are two perspectives on test writing: Classicist and Mockist. Recently, the Classicist approach has gained more support because it enables more realistic testing and higher maintainability by using actual objects.

Easily testable code has three characteristics:

First, it guarantees deterministic behavior. It always returns the same result for the same input, so it doesn’t require mocks as it’s not affected by external dependencies or state.

Second, it minimizes side effects. It operates purely without changing external state, making mocks for external systems unnecessary.

Third, it has well-separated responsibilities. External dependencies and business logic are clearly distinguished, and pure functions are isolated from external factors, making the test scope clear.

Such code naturally avoids various drawbacks of mocking, such as inconsistencies with actual logic, complexity from excessive use, and behavioral differences from real objects.

Easily Testable Code vs. Hard-to-Test Code

Easily Testable Code

This is code without side effects where results depend only on arguments. It’s also called an idempotent pure function, meaning it always returns the same output for the same input.

Code to Avoid

Testing difficulties propagate.

Code Dependent on Uncontrollable Values

  • Dependency on functions that return different results each time, like new Date()
  • Code dependent on browser functions, global variables, or other external scopes
  • Code dependent on external conditions like payment gateway libraries

For example, let’s look at a function that applies a 10% discount on orders made on Sundays:

// Hard-to-test code
class OrderService {
  discount(order: Order) {
    const now = LocalDateTime.now(); // Depends on uncontrollable value

    if (now.dayOfWeek() === DayOfWeek.SUNDAY) {
      return order.amount * 0.9;
    }
    return order.amount;
  }
}

This code is difficult to test because it depends on the current time. The discount only applies on Sundays, so results vary depending on when the test runs.

Improvement

Code with external dependencies should be pushed to the outer layers as much as possible

For example, the outer layers of a web application are:

  • Controller, Presentation layer for backend
  • View Component, View Layer for frontend

In a layered architecture, it’s best to push external dependencies to the outermost layer. This way, only the controller becomes difficult to test, while other layers like services become easier to test.

However, this should be done without compromising the overall structure.

Let’s look at two improvement methods:

Method 1: Pass uncontrollable values as parameters
class OrderService {
  // Use current time as default, but allow injection of desired time in tests
  discount(order: Order, now = LocalDateTime.now()) {
    if (now.dayOfWeek() === DayOfWeek.SUNDAY) {
      return order.amount * 0.9;
    }
    return order.amount;
  }
}

However, this approach has issues:

  1. Controller testing remains difficult
  2. Many functions must always accept uncontrollable parameters
Method 2: Solve with Dependency Injection (DI)
// time.interface.ts
export interface Time {
  now(): Date;
}

// prod-time.ts
export class ProdTime implements Time {
  now(): Date {
    return new Date();
  }
}

// stub-time.ts (for testing)
export class StubTime implements Time {
  constructor(private readonly stubbedDate: Date = new Date()) {}

  now(): Date {
    return this.stubbedDate;
  }
}

// order.service.ts
class OrderService {
  constructor(private readonly time: Time) {}

  discount(order: Order) {
    const now = this.time.now();

    if (now.getDay() === 0) { // 0 is Sunday
      return order.amount * 0.9;
    }
    return order.amount;
  }
}

Using dependency injection:

  1. Inject ProdTime in production environment to use actual time
  2. Inject StubTime in tests to use desired time
  3. Encapsulate time-related logic through the Time interface

This way, we can push hard-to-test code to the outermost layer while keeping core business logic pure and easily testable.

Code Affected by External Factors

  • External output
  • External input
  • Using Loggers
  • Message sending
@Entity()
export class Order {
  // ...

  // Method mixing DB logic and domain logic
  // TypeORM example
  // When order is canceled
  // Create a cancellation order
  // Through the original order
  // And save to database
  async cancel(reason: string): Promise<Order> {
    // Domain logic
    if (this.status !== OrderStatus.PENDING && 
        this.status !== OrderStatus.PAID) {
      throw new Error('Order cannot be canceled in current state.');
    }

    // DB logic exists within entity
    const cancelOrder = new Order();
    cancelOrder.amount = this.amount;
    cancelOrder.status = OrderStatus.CANCELED;
    cancelOrder.cancelReason = reason;

    // TypeORM's getConnection used directly within entity
    // -> Hard to test code
    return await getConnection()
      .getRepository(Order)
      .save(cancelOrder);
  }
}

// Service acting as simple pass-through
@Injectable()
export class OrderService {
  async cancelOrder(orderId: string, reason: string): Promise<Order> {
    const order = await getConnection()
      .getRepository(Order)
      .findOne(orderId);

    if (!order) {
      throw new NotFoundException();
    }

    return await order.cancel(reason);
  }
}

This code is difficult to test for the following reasons:

  1. DB access logic (getConnection()) is directly inside Order entity
  2. OrderService also directly uses getConnection()
  3. Domain logic (checking cancellation possibility, creating cancellation order) is mixed with DB logic

In other words, uncontrollable things (DB connection, storage, etc.) make testing difficult.

Improvement

To test the ‘DB-dependent logic’ of Order - order cancellation logic, we need to:

  • Set up test DB
  • Close test database connection
  • Initialize test tables
    before we can verify what we actually want to verify.

These steps include many problems:

  1. Low test refactoring durability
    • If code changes, tests need major modifications
    • What if we need to change to external API calls? Switch to NoSQL? Lots of code changes would be needed
  2. Difficult to ensure test data consistency
    • What if identical values already exist in test DB?
    • Can be affected by other tests
  3. Slow test execution speed
    • Database operations slow down test execution

So how can we improve this? It’s better to separate database dependency from Order logic.

Database logic separated from Order should be placed in OrderService, and integration tests should be written for OrderService.

// order.entity.ts
@Entity()
export class Order {
  // ...

  // Pure domain logic - easy to unit test
  cancel(reason: string): Order {
    if (!this.isCancelable()) {
      throw new Error('Order cannot be canceled in current state.');
    }

    const cancelOrder = new Order();
    cancelOrder.amount = this.amount;
    cancelOrder.status = OrderStatus.CANCELED;
    cancelOrder.cancelReason = reason;

    return cancelOrder;
  }

  // ...
}

// order.service.ts
@Injectable()
export class OrderService {
  constructor(
    @InjectRepository(Order)
    private readonly orderRepository: OrderRepository,
  ) {}

  // DB save logic separated to service layer
  async cancelOrder(orderId: string, reason: string): Promise<Order> {
    const order = await this.orderRepository.findOne(orderId);
    if (!order) {
      throw new NotFoundException();
    }

    // Call pure domain logic
    const cancelOrder = order.cancel(reason);

    // Hard-to-test code
    return await getConnection()
      .getRepository(Order)
      .save(cancelOrder);
  }
}

Logic with Many Private Functions

Let’s assume OrderService has a receipt function that ‘creates and saves an order based on input order amount’:

class OrderService {
  async receipt(...) {
    this.validatePositive(amount);     // How to test private function?
    this.validateInteger(amount);      // How to test private function?
    const order = Order.create(amount, description);
    await this.orderRepository.save(order);
  }

  private validatePositive(amount) {
    // Implementation
  }

  private validateInteger(amount) {
    // Implementation
  }
}

More private functions mean more points to test. Private functions are difficult to test, so what should we do?

“Make them public functions of a new, cohesive class”

For example, in the above code, there are two validate functions related to amount. These can be extracted into a Money class.

class Money {
  readonly amount: number;

  // Now validation can be tested through Money class constructor!
  constructor(amount: number) {
    this.validatePositive(amount);    
    this.validateInteger(amount);
    this.amount = amount;
  }

  private validatePositive(amount: number) {
    // Implementation
  }

  private validateInteger(amount: number) {
    // Implementation
  }
}

class OrderService {
  async receipt(amount: number, description: string) {
    const money = new Money(amount); // Validation logic moved to Money!
    const order = Order.create(money.amount, description);
    await this.orderRepository.save(order); 
  } 
}

The key point is that having many private methods might signal “there’s logic that shouldn’t be here or needs to be cohesive.”

Conclusion

To summarize, good functions have these characteristics:

  • Pure functions without side effects where return values depend only on arguments
  • Can be used as stable public names in global namespace
  • Computational work increases only linearly with respect to the structural size of input arguments

Striving to create such good functions naturally leads to easily testable code.
Why? Because easily testable code shares characteristics with pure functions.

A good testable codebase has a structure that pushes side effects to external boundaries like data and UI layers, making core business logic easy to test.

If you understand the principles of creating easily testable code well, you can remain stable regardless of which library or framework emerges.