Skip to content

Unit Testing in NestJS

Testing business logic in isolation without framework dependencies

Related: Integration Testing in NestJS | Unit Testing | Test-Driven Development

Overview

Unit tests in our NestJS applications focus on testing business logic in complete isolation. Domain entities, value objects, and use cases are tested without any framework dependencies, database connections, or external services.

Testing Philosophy

Pure Unit Tests

Our unit tests follow these principles:

  • No NestJS testing module for domain and use case tests
  • No real implementations of repositories or services
  • Fast execution - milliseconds, not seconds
  • Complete isolation - test one thing at a time
  • Framework-agnostic - business logic doesn't know about NestJS

Domain Layer Testing

Testing Entities

Domain entities contain core business rules that must be thoroughly tested:

typescript
// domain/order.entity.spec.ts
describe('Order Entity', () => {
  describe('create', () => {
    it('should create order with valid items', () => {
      const customerId = new CustomerId('customer-123');
      const items = [
        OrderItem.create('product-1', 2, new Money(10, 'USD')),
        OrderItem.create('product-2', 1, new Money(25, 'USD')),
      ];

      const order = Order.create(customerId, items);

      expect(order.getCustomerId()).toEqual(customerId);
      expect(order.getItems()).toHaveLength(2);
      expect(order.getTotal()).toEqual(new Money(45, 'USD'));
      expect(order.getStatus()).toBe(OrderStatus.PENDING);
    });

    it('should throw error for empty order', () => {
      const customerId = new CustomerId('customer-123');
      const items: OrderItem[] = [];

      expect(() => Order.create(customerId, items))
        .toThrow(EmptyOrderError);
    });

    it('should calculate total correctly', () => {
      const items = [
        OrderItem.create('product-1', 3, new Money(15.50, 'USD')),
        OrderItem.create('product-2', 2, new Money(20.00, 'USD')),
      ];

      const order = Order.create(
        new CustomerId('customer-123'),
        items,
      );

      expect(order.getTotal()).toEqual(new Money(86.50, 'USD'));
    });
  });

  describe('ship', () => {
    it('should ship paid order', () => {
      const order = createPaidOrder();

      order.ship();

      expect(order.getStatus()).toBe(OrderStatus.SHIPPED);
      expect(order.getShippedAt()).toBeDefined();
    });

    it('should throw error when shipping unpaid order', () => {
      const order = createPendingOrder();

      expect(() => order.ship())
        .toThrow(new InvalidOrderStateError('Cannot ship unpaid order'));
    });

    it('should throw error when shipping already shipped order', () => {
      const order = createShippedOrder();

      expect(() => order.ship())
        .toThrow(new InvalidOrderStateError('Order already shipped'));
    });
  });

  describe('cancel', () => {
    it('should cancel pending order', () => {
      const order = createPendingOrder();
      const reason = 'Customer request';

      order.cancel(reason);

      expect(order.getStatus()).toBe(OrderStatus.CANCELLED);
      expect(order.getCancellationReason()).toBe(reason);
    });

    it('should throw error when cancelling shipped order', () => {
      const order = createShippedOrder();

      expect(() => order.cancel('Too late'))
        .toThrow(new InvalidOrderStateError('Cannot cancel shipped order'));
    });
  });

  describe('applyDiscount', () => {
    it('should apply valid discount', () => {
      const order = Order.create(
        new CustomerId('customer-123'),
        [OrderItem.create('product-1', 1, new Money(100, 'USD'))],
      );

      const discount = new Money(10, 'USD');
      order.applyDiscount(discount);

      expect(order.getTotal()).toEqual(new Money(90, 'USD'));
      expect(order.getDiscount()).toEqual(discount);
    });

    it('should throw error for discount exceeding total', () => {
      const order = Order.create(
        new CustomerId('customer-123'),
        [OrderItem.create('product-1', 1, new Money(50, 'USD'))],
      );

      const discount = new Money(60, 'USD');

      expect(() => order.applyDiscount(discount))
        .toThrow(new InvalidDiscountError('Discount cannot exceed order total'));
    });

    it('should throw error for discount in different currency', () => {
      const order = Order.create(
        new CustomerId('customer-123'),
        [OrderItem.create('product-1', 1, new Money(100, 'USD'))],
      );

      const discount = new Money(10, 'EUR');

      expect(() => order.applyDiscount(discount))
        .toThrow(new CurrencyMismatchError());
    });
  });
});

Testing Value Objects

Value objects encapsulate domain concepts with validation:

typescript
// domain/value-objects/email.spec.ts
describe('Email Value Object', () => {
  describe('constructor', () => {
    it('should create valid email', () => {
      const email = new Email('user@example.com');
      
      expect(email.getValue()).toBe('user@example.com');
    });

    it('should normalize email to lowercase', () => {
      const email = new Email('User@Example.COM');
      
      expect(email.getValue()).toBe('user@example.com');
    });

    it('should throw error for invalid format', () => {
      expect(() => new Email('invalid'))
        .toThrow(InvalidEmailError);
      
      expect(() => new Email('missing@domain'))
        .toThrow(InvalidEmailError);
      
      expect(() => new Email('@example.com'))
        .toThrow(InvalidEmailError);
    });

    it('should throw error for empty email', () => {
      expect(() => new Email(''))
        .toThrow(InvalidEmailError);
    });
  });

  describe('equals', () => {
    it('should be equal for same email', () => {
      const email1 = new Email('user@example.com');
      const email2 = new Email('user@example.com');
      
      expect(email1.equals(email2)).toBe(true);
    });

    it('should be equal for different case', () => {
      const email1 = new Email('user@example.com');
      const email2 = new Email('USER@EXAMPLE.COM');
      
      expect(email1.equals(email2)).toBe(true);
    });

    it('should not be equal for different emails', () => {
      const email1 = new Email('user1@example.com');
      const email2 = new Email('user2@example.com');
      
      expect(email1.equals(email2)).toBe(false);
    });
  });

  describe('getDomain', () => {
    it('should extract domain', () => {
      const email = new Email('user@example.com');
      
      expect(email.getDomain()).toBe('example.com');
    });
  });
});

Testing Domain Services

Domain services contain business logic that doesn't belong to a single entity:

typescript
// domain/services/pricing.service.spec.ts
describe('PricingDomainService', () => {
  let service: PricingDomainService;

  beforeEach(() => {
    service = new PricingDomainService();
  });

  describe('calculateDiscount', () => {
    it('should apply VIP customer discount', () => {
      const order = createOrder(new Money(100, 'USD'));
      const customer = createVipCustomer();
      const promotions: Promotion[] = [];

      const discount = service.calculateDiscount(order, customer, promotions);

      expect(discount).toEqual(new Money(10, 'USD')); // 10% VIP discount
    });

    it('should apply promotion discount', () => {
      const order = createOrder(new Money(100, 'USD'));
      const customer = createRegularCustomer();
      const promotions = [
        new PercentagePromotion('SUMMER20', 0.20),
      ];

      const discount = service.calculateDiscount(order, customer, promotions);

      expect(discount).toEqual(new Money(20, 'USD'));
    });

    it('should combine VIP and promotion discounts', () => {
      const order = createOrder(new Money(100, 'USD'));
      const customer = createVipCustomer();
      const promotions = [
        new PercentagePromotion('EXTRA10', 0.10),
      ];

      const discount = service.calculateDiscount(order, customer, promotions);

      expect(discount).toEqual(new Money(20, 'USD')); // 10% VIP + 10% promo
    });

    it('should cap discount at maximum allowed', () => {
      const order = createOrder(new Money(100, 'USD'));
      const customer = createVipCustomer();
      const promotions = [
        new PercentagePromotion('MEGA50', 0.50),
        new FixedPromotion('EXTRA20', new Money(20, 'USD')),
      ];

      const discount = service.calculateDiscount(order, customer, promotions);

      expect(discount).toEqual(new Money(50, 'USD')); // Capped at 50%
    });
  });

  describe('calculateShipping', () => {
    it('should calculate standard shipping', () => {
      const order = createOrder(new Money(50, 'USD'));
      const address = createDomesticAddress();

      const shipping = service.calculateShipping(order, address);

      expect(shipping).toEqual(new Money(10, 'USD'));
    });

    it('should provide free shipping for orders over threshold', () => {
      const order = createOrder(new Money(150, 'USD'));
      const address = createDomesticAddress();

      const shipping = service.calculateShipping(order, address);

      expect(shipping).toEqual(new Money(0, 'USD'));
    });

    it('should calculate international shipping', () => {
      const order = createOrder(new Money(50, 'USD'));
      const address = createInternationalAddress();

      const shipping = service.calculateShipping(order, address);

      expect(shipping).toEqual(new Money(25, 'USD'));
    });
  });
});

Use Case Layer Testing

Testing Use Cases with Stubs

Use cases orchestrate domain logic and external dependencies:

typescript
// use-cases/create-order.use-case.spec.ts
describe('CreateOrderUseCase', () => {
  let useCase: CreateOrderUseCase;
  let orderRepository: StubOrderRepository;
  let inventoryService: StubInventoryService;
  let paymentService: StubPaymentService;
  let pricingService: PricingDomainService;

  beforeEach(() => {
    orderRepository = new StubOrderRepository();
    inventoryService = new StubInventoryService();
    paymentService = new StubPaymentService();
    pricingService = new PricingDomainService();

    useCase = new CreateOrderUseCase(
      orderRepository,
      inventoryService,
      paymentService,
      pricingService,
    );
  });

  describe('execute', () => {
    it('should create order successfully', async () => {
      const request: CreateOrderRequest = {
        customerId: 'customer-123',
        items: [
          { productId: 'product-1', quantity: 2, price: 10 },
          { productId: 'product-2', quantity: 1, price: 25 },
        ],
      };

      inventoryService.setAvailable(true);
      paymentService.setPaymentSuccess(true);

      const response = await useCase.execute(request);

      expect(response.orderId).toBeDefined();
      expect(response.status).toBe(OrderStatus.PAID);
      expect(response.total).toEqual({ amount: 45, currency: 'USD' });

      // Verify interactions
      expect(inventoryService.checkAvailabilityCalled).toBe(true);
      expect(inventoryService.reserveCalled).toBe(true);
      expect(paymentService.chargeCalled).toBe(true);
      expect(orderRepository.savedOrders).toHaveLength(1);
    });

    it('should throw error for insufficient inventory', async () => {
      const request = createOrderRequest();
      
      inventoryService.setAvailable(false);
      inventoryService.setUnavailableItems([
        { productId: 'product-1', requested: 5, available: 2 },
      ]);

      await expect(useCase.execute(request))
        .rejects
        .toThrow(new InsufficientInventoryError('product-1', 5, 2));

      expect(inventoryService.reserveCalled).toBe(false);
      expect(paymentService.chargeCalled).toBe(false);
      expect(orderRepository.savedOrders).toHaveLength(0);
    });

    it('should release inventory on payment failure', async () => {
      const request = createOrderRequest();
      
      inventoryService.setAvailable(true);
      paymentService.setPaymentSuccess(false);
      paymentService.setFailureReason('Insufficient funds');

      await expect(useCase.execute(request))
        .rejects
        .toThrow(new PaymentFailedError('Insufficient funds'));

      expect(inventoryService.reserveCalled).toBe(true);
      expect(inventoryService.releaseCalled).toBe(true);
      expect(orderRepository.savedOrders).toHaveLength(0);
    });

    it('should apply customer discount', async () => {
      const request = createOrderRequest();
      
      inventoryService.setAvailable(true);
      paymentService.setPaymentSuccess(true);
      orderRepository.setCustomer(createVipCustomer());

      const response = await useCase.execute(request);

      // VIP gets 10% discount
      expect(response.total).toEqual({ amount: 40.50, currency: 'USD' });
    });
  });
});

Creating Test Stubs

Manual stubs for complete control:

typescript
// test/stubs/order-repository.stub.ts
export class StubOrderRepository implements OrderRepositoryInterface {
  public savedOrders: Order[] = [];
  private nextOrder: Order | null = null;
  private customer: Customer | null = null;

  async save(order: Order): Promise<void> {
    this.savedOrders.push(order);
  }

  async findById(id: OrderId): Promise<Order | null> {
    return this.savedOrders.find(o => o.getId().equals(id)) || null;
  }

  async findByCustomer(customerId: CustomerId): Promise<Order[]> {
    return this.savedOrders.filter(o => 
      o.getCustomerId().equals(customerId)
    );
  }

  // Test helpers
  setNextOrder(order: Order): void {
    this.nextOrder = order;
  }

  setCustomer(customer: Customer): void {
    this.customer = customer;
  }

  getLastSavedOrder(): Order | undefined {
    return this.savedOrders[this.savedOrders.length - 1];
  }

  clear(): void {
    this.savedOrders = [];
    this.nextOrder = null;
  }
}

// test/stubs/inventory-service.stub.ts
export class StubInventoryService implements InventoryServiceInterface {
  public checkAvailabilityCalled = false;
  public reserveCalled = false;
  public releaseCalled = false;
  
  private available = true;
  private unavailableItems: UnavailableItem[] = [];

  async checkAvailability(items: OrderItem[]): Promise<AvailabilityResult> {
    this.checkAvailabilityCalled = true;
    
    return {
      isAvailable: this.available,
      unavailableItems: this.unavailableItems,
    };
  }

  async reserve(orderId: OrderId, items: OrderItem[]): Promise<void> {
    this.reserveCalled = true;
  }

  async release(orderId: OrderId): Promise<void> {
    this.releaseCalled = true;
  }

  // Test configuration
  setAvailable(available: boolean): void {
    this.available = available;
  }

  setUnavailableItems(items: UnavailableItem[]): void {
    this.unavailableItems = items;
  }

  reset(): void {
    this.checkAvailabilityCalled = false;
    this.reserveCalled = false;
    this.releaseCalled = false;
    this.available = true;
    this.unavailableItems = [];
  }
}

Testing Without NestJS

Pure TypeScript Tests

Domain and use case tests don't need NestJS:

typescript
// No NestJS imports needed!
describe('User Entity', () => {
  it('should change email', () => {
    const user = User.create(
      new Email('old@example.com'),
      'John Doe',
      'password123',
    );

    const newEmail = new Email('new@example.com');
    user.changeEmail(newEmail);

    expect(user.getEmail()).toEqual(newEmail);
    expect(user.getUpdatedAt()).toBeCloseTo(Date.now(), -2);
  });

  it('should record email change event', () => {
    const user = User.create(
      new Email('old@example.com'),
      'John Doe',
      'password123',
    );

    user.changeEmail(new Email('new@example.com'));

    const events = user.getUncommittedEvents();
    expect(events).toHaveLength(1);
    expect(events[0]).toBeInstanceOf(EmailChangedEvent);
    expect(events[0].userId).toEqual(user.getId());
    expect(events[0].newEmail).toBe('new@example.com');
  });
});

Testing Business Rules

Focus on business behavior, not technical details:

typescript
describe('Order Business Rules', () => {
  describe('Order modification rules', () => {
    it('should allow adding items to pending order', () => {
      const order = createPendingOrder();
      const newItem = OrderItem.create('product-3', 1, new Money(30, 'USD'));

      order.addItem(newItem);

      expect(order.getItems()).toContainEqual(newItem);
    });

    it('should not allow adding items to shipped order', () => {
      const order = createShippedOrder();
      const newItem = OrderItem.create('product-3', 1, new Money(30, 'USD'));

      expect(() => order.addItem(newItem))
        .toThrow(OrderNotModifiableError);
    });
  });

  describe('Refund rules', () => {
    it('should allow full refund within 30 days', () => {
      const order = createRecentOrder();

      const refundAmount = order.requestRefund();

      expect(refundAmount).toEqual(order.getTotal());
    });

    it('should allow 50% refund after 30 days', () => {
      const order = createOldOrder(45); // 45 days old

      const refundAmount = order.requestRefund();

      expect(refundAmount).toEqual(order.getTotal().multiply(0.5));
    });

    it('should not allow refund after 90 days', () => {
      const order = createOldOrder(91);

      expect(() => order.requestRefund())
        .toThrow(RefundNotAllowedError);
    });
  });
});

Test Organization

Test File Structure

src/
├── modules/
│   └── orders/
│       ├── domain/
│       │   ├── order.entity.ts
│       │   ├── order.entity.spec.ts
│       │   ├── value-objects/
│       │   │   ├── order-id.ts
│       │   │   ├── order-id.spec.ts
│       │   │   ├── money.ts
│       │   │   └── money.spec.ts
│       │   └── services/
│       │       ├── pricing.service.ts
│       │       └── pricing.service.spec.ts
│       └── use-cases/
│           ├── create-order.use-case.ts
│           ├── create-order.use-case.spec.ts
│           ├── ship-order.use-case.ts
│           └── ship-order.use-case.spec.ts
test/
└── stubs/
    ├── order-repository.stub.ts
    ├── inventory-service.stub.ts
    └── payment-service.stub.ts

Test Helpers

Create factories for common test scenarios:

typescript
// test/factories/order.factory.ts
export class OrderFactory {
  static createPending(): Order {
    return Order.create(
      new CustomerId('customer-123'),
      [OrderItem.create('product-1', 1, new Money(100, 'USD'))],
    );
  }

  static createPaid(): Order {
    const order = this.createPending();
    order.markAsPaid('transaction-123');
    return order;
  }

  static createShipped(): Order {
    const order = this.createPaid();
    order.ship();
    return order;
  }

  static createWithItems(items: OrderItem[]): Order {
    return Order.create(
      new CustomerId('customer-123'),
      items,
    );
  }

  static createOldOrder(daysAgo: number): Order {
    const order = this.createShipped();
    const oldDate = new Date();
    oldDate.setDate(oldDate.getDate() - daysAgo);
    
    // Use reflection for testing
    (order as any).createdAt = oldDate;
    
    return order;
  }
}

Best Practices

DO ✅

  • Test behavior, not implementation - Focus on what, not how
  • Use descriptive test names - Should read like specifications
  • Keep tests simple - One assertion per test when possible
  • Test edge cases - Empty arrays, null values, boundaries
  • Use test builders - For complex object creation

DON'T ❌

  • Use NestJS testing module for domain/use case tests
  • Mock what you don't own - Use adapters instead
  • Test private methods - Test through public interface
  • Share state between tests - Each test should be independent
  • Test framework code - Focus on your business logic

Testing Checklist

Domain Entity Tests

  • [ ] Creation with valid data
  • [ ] Creation with invalid data (validation)
  • [ ] State transitions
  • [ ] Business rule enforcement
  • [ ] Invariant protection
  • [ ] Event generation

Value Object Tests

  • [ ] Creation with valid values
  • [ ] Validation rules
  • [ ] Equality comparison
  • [ ] Immutability
  • [ ] Derived properties

Use Case Tests

  • [ ] Happy path execution
  • [ ] Error scenarios
  • [ ] Dependency interactions
  • [ ] Transaction boundaries
  • [ ] Event publishing
  • [ ] Return values

Next Steps