Appearance
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.tsTest 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
- Explore Integration Testing in NestJS for end-to-end testing
- Review Clean Architecture in NestJS for testable design
- Study Test-Driven Development methodology
Related Concepts
- Unit Testing - Testing principles
- Test-Driven Development - TDD methodology
- Clean Architecture - Testable architecture