Category: Development

  • 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.

  • Prisma vs TypeORM: A Comparison for Node.js Developers

    Introduction: Choosing Between Prisma and TypeORM

    When building Node.js applications, choosing between Prisma and TypeORM can significantly impact your project’s success. This comprehensive comparison explores how these leading Object-Relational Mapping (ORM) solutions differ in architecture, performance, and developer experience. Whether you’re starting a new project or considering a migration, understanding these differences is crucial for making an informed decision.

    Core Architectural Differences

    Prisma’s Modern Schema-First Approach

    Prisma revolutionizes database interaction through its schema-first methodology, utilizing the Prisma Schema Language (PSL). This approach provides:

    • Auto-generated, type-safe client interfaces
    • Dedicated query engine optimization
    • Database-agnostic implementation
    • Enhanced developer experience with strong typing
    // Prisma schema example
    model User {
      id      Int      @id @default(autoincrement())
      email   String   @unique
      posts   Post[]
      profile Profile?
    }

    TypeORM’s Traditional ORM Implementation

    TypeORM follows a more conventional ORM architecture, offering:

    • Flexible code-first or schema-first approaches
    • Direct database interaction capabilities
    • TypeScript decorator-based entity definitions
    • Object-oriented pattern adherence
    // TypeORM entity example
    @Entity()
    class User {
      @PrimaryGeneratedColumn()
      id: number;
    
      @Column({ unique: true })
      email: string;
    
      @OneToMany(() => Post, post => post.user)
      posts: Post[];
    }

    Performance and Scalability Analysis

    Query Optimization Capabilities

    Prisma’s Smart Query Planning

    • Automatic query batching
    • Built-in N+1 problem prevention
    • Efficient connection pooling
    • Type-safe transaction handling

    TypeORM’s Traditional Approach

    • Manual query optimization options
    • Custom repository patterns
    • Database-specific optimizations
    • Direct SQL access when needed

    Scaling Considerations

    Both ORMs handle scaling differently:

    Prisma:

    • Smart chunking for large datasets
    • Automatic connection management
    • Built-in query batching
    • Type-safe middleware system

    TypeORM:

    • Manual optimization requirements
    • Traditional connection pooling
    • Custom event subscriber system
    • Flexible transaction control

    Making the Right Choice

    Choose Prisma When:

    • Starting new TypeScript projects
    • Prioritizing developer experience
    • Requiring robust type safety
    • Needing rapid development cycles

    Choose TypeORM When:

    • Working with legacy systems
    • Requiring fine-grained control
    • Preferring traditional ORM patterns
    • Needing complex database operations

    Conclusion

    Both Prisma and TypeORM offer robust solutions for Node.js applications, each with distinct advantages. Prisma excels in modern development workflows with its strong type safety and developer experience, while TypeORM provides more traditional ORM capabilities with greater control over database operations.

    Additional Resources

    Last Updated: December 2024