Loading learning content...
In a microservices architecture, where dozens or hundreds of services interact in complex choreographies, unit testing becomes your first and most critical line of defense. Unlike monolithic applications where a single test suite can exercise broad swaths of functionality, microservices demand a fundamentally different approach—one where each service must be verifiable in complete isolation, yet provide confidence that it will behave correctly when composed with others.
Unit testing in microservices isn't just about testing functions or classes. It's about establishing behavioral contracts that each service must honor, creating a foundation of trust that enables the entire distributed system to evolve with confidence.
By the end of this page, you will understand how to design and implement effective unit testing strategies for microservices, including isolation techniques, test doubles, domain logic testing, and how to structure test suites that remain maintainable as services evolve. You'll learn patterns used by elite engineering teams at companies like Netflix, Amazon, and Google to ensure individual service quality.
Unit testing in microservices requires a subtle but important shift in thinking. In traditional monolithic applications, a 'unit' typically refers to a single function, method, or class. In microservices, the definition expands to include the service as a unit of behavior—while still testing its internal components in isolation.
This dual perspective is essential:
Component-Level Unit Testing: Testing individual classes, functions, and modules within a service to verify their logic is correct.
Behavioral Unit Testing: Testing the service's public interfaces (APIs, event handlers, message consumers) with all external dependencies mocked or stubbed, to verify the service implements its contract correctly.
Both levels are necessary. Component-level tests catch logic bugs early. Behavioral tests ensure the service fulfills its responsibilities to the broader system.
A critical design decision is where to draw the unit boundary. Too narrow (testing individual methods in isolation) leads to brittle tests that break with refactoring. Too broad (testing multiple components together) slows execution and obscures failure causes. The sweet spot: test behavioral units—classes or modules that represent a coherent piece of functionality, with clear inputs and outputs.
| Test Level | What's Tested | External Dependencies | Speed | Isolation |
|---|---|---|---|---|
| Method/Function | Single function logic | All mocked | < 1ms | Complete |
| Class/Module | Class behavior with collaborators | External only mocked | < 10ms | High |
| Component | Domain logic unit | I/O mocked | < 100ms | Medium-High |
| Service Behavioral | API/Event handlers | All external mocked | < 500ms | Medium |
The Testing Pyramid in Microservices Context:
The classic testing pyramid—many unit tests, fewer integration tests, even fewer E2E tests—remains valid but requires adaptation for microservices:
The key insight: in microservices, the pyramid is replicated for each service. Every service has its own pyramid, and the aggregate provides system-wide quality assurance.
Not all code in a microservice merits equal testing attention. Strategic focus on high-value targets maximizes quality impact per testing effort. Understanding what to test—and what not to test—is a hallmark of mature engineering teams.
High-Priority Unit Test Targets:
Lower-Priority Unit Test Targets:
High code coverage doesn't equal high-quality tests. 100% coverage with shallow assertion-free tests provides false confidence. Focus on testing behavior, not lines of code. A well-designed test suite might have 70% coverage but catch 95% of bugs because it targets the right code with meaningful assertions.
Test doubles are the essential tools for achieving isolation in unit tests. They replace real dependencies with controlled substitutes, allowing you to test a unit's behavior independent of its collaborators. Understanding the distinctions and appropriate use cases for each type is fundamental to effective microservices testing.
The Test Double Taxonomy:
| Type | Definition | Use Case | Verification |
|---|---|---|---|
| Dummy | Objects passed but never used | Satisfy parameter requirements | None |
| Stub | Provides canned answers to calls | Control indirect inputs | None (state verification) |
| Spy | Records calls for later inspection | Verify interactions happened | Call recording |
| Mock | Pre-programmed with expectations | Verify specific interactions | Behavior verification |
| Fake | Working implementation simplified | Complex dependencies needing behavior | May verify state or behavior |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
// STUB: Returns predetermined responsesclass PaymentGatewayStub implements PaymentGateway { async processPayment(amount: Money): Promise<PaymentResult> { return { success: true, transactionId: "stub-txn-123" }; }} // MOCK: Verifies specific interactionstest("should process payment with correct amount", async () => { const mockGateway = mock<PaymentGateway>(); when(mockGateway.processPayment(anything())).thenResolve({ success: true, transactionId: "mock-123" }); const service = new OrderService(instance(mockGateway)); await service.checkout(orderId); verify(mockGateway.processPayment( deepEqual({ amount: 99.99, currency: "USD" }) )).once();}); // FAKE: Simplified working implementationclass InMemoryOrderRepository implements OrderRepository { private orders = new Map<string, Order>(); async save(order: Order): Promise<void> { this.orders.set(order.id, order); } async findById(id: string): Promise<Order | null> { return this.orders.get(id) ?? null; } async findByStatus(status: OrderStatus): Promise<Order[]> { return [...this.orders.values()] .filter(o => o.status === status); }} // SPY: Records calls for inspectionclass NotificationServiceSpy implements NotificationService { private sentNotifications: Notification[] = []; async send(notification: Notification): Promise<void> { this.sentNotifications.push(notification); } getSentNotifications(): Notification[] { return [...this.sentNotifications]; } wasCalled(): boolean { return this.sentNotifications.length > 0; }}Choosing the Right Test Double:
Use Stubs when you need to control what a dependency returns but don't care how it's called. Stubs are ideal for setting up test scenarios (e.g., 'what happens when the database returns empty results?').
Use Mocks when the interaction itself is what you're testing—verifying that your code calls the right method with the right arguments in the right sequence. Overusing mocks leads to brittle tests.
Use Fakes for complex dependencies that are expensive to mock fully. In-memory databases, file systems, and message queues are common fake candidates. Fakes require maintenance but provide more realistic behavior.
Use Spies when you need to observe behavior without constraining it. Spies are less prescriptive than mocks—they record what happened for later assertion.
Excessive mocking is a code smell. If you're mocking everything, your unit under test may have too many dependencies—a sign of poor design. Well-designed services have a core of pure business logic with minimal dependencies, surrounded by an 'infrastructure shell' that handles I/O. The pure core is easily unit-tested; the shell is integration-tested.
Domain logic—the business rules and calculations that define your service's core value—demands the most rigorous unit testing attention. This code changes frequently, contains subtle edge cases, and directly impacts business outcomes when incorrect.
Principles for Domain Logic Testing:
Test Behavior, Not Implementation: Assert on outcomes, not internal state or method call sequences. Tests should describe what the system does, not how.
Use Domain Language: Test names and assertions should use business terminology. 'test_order_with_expired_coupon_does_not_apply_discount' is clearer than 'test_validateCoupon_returns_false'.
Cover Edge Cases Exhaustively: Business rules often have complex boundary conditions. What happens at midnight? On leap years? With empty collections? Zero amounts?
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
// Domain entity with complex business rulesclass Order { private items: OrderItem[] = []; private discount: Discount | null = null; private status: OrderStatus = OrderStatus.DRAFT; addItem(product: Product, quantity: number): void { if (this.status !== OrderStatus.DRAFT) { throw new OrderModificationError("Cannot modify confirmed order"); } if (quantity <= 0) { throw new InvalidQuantityError("Quantity must be positive"); } const existingItem = this.items.find(i => i.productId === product.id); if (existingItem) { existingItem.quantity += quantity; } else { this.items.push(new OrderItem(product.id, product.price, quantity)); } } applyDiscount(discount: Discount): void { if (this.status !== OrderStatus.DRAFT) { throw new OrderModificationError("Cannot modify confirmed order"); } if (discount.isExpired()) { throw new ExpiredDiscountError("Discount has expired"); } if (discount.minimumOrderValue && this.subtotal < discount.minimumOrderValue) { throw new MinimumNotMetError( `Order subtotal ${this.subtotal} below minimum ${discount.minimumOrderValue}` ); } this.discount = discount; } get total(): Money { const subtotal = this.subtotal; const discountAmount = this.calculateDiscountAmount(subtotal); return subtotal - discountAmount; } private calculateDiscountAmount(subtotal: Money): Money { if (!this.discount) return Money.zero(); if (this.discount.type === DiscountType.PERCENTAGE) { return subtotal.multiply(this.discount.value / 100); } return Money.min(this.discount.value, subtotal); // Don't go negative }} // Comprehensive unit tests for domain logicdescribe("Order", () => { describe("addItem", () => { it("should add new item to empty order", () => { const order = new Order(); const product = createProduct({ price: Money.of(29.99) }); order.addItem(product, 2); expect(order.items).toHaveLength(1); expect(order.subtotal).toEqual(Money.of(59.98)); }); it("should increment quantity for existing product", () => { const order = new Order(); const product = createProduct({ id: "prod-1", price: Money.of(10) }); order.addItem(product, 2); order.addItem(product, 3); expect(order.items).toHaveLength(1); expect(order.items[0].quantity).toBe(5); }); it("should reject zero quantity", () => { const order = new Order(); const product = createProduct(); expect(() => order.addItem(product, 0)).toThrow(InvalidQuantityError); }); it("should reject negative quantity", () => { const order = new Order(); const product = createProduct(); expect(() => order.addItem(product, -1)).toThrow(InvalidQuantityError); }); it("should reject modification after confirmation", () => { const order = createConfirmedOrder(); const product = createProduct(); expect(() => order.addItem(product, 1)).toThrow(OrderModificationError); }); }); describe("applyDiscount", () => { it("should apply percentage discount correctly", () => { const order = createOrderWithSubtotal(Money.of(100)); const discount = createPercentageDiscount(20); // 20% off order.applyDiscount(discount); expect(order.total).toEqual(Money.of(80)); }); it("should apply fixed amount discount correctly", () => { const order = createOrderWithSubtotal(Money.of(100)); const discount = createFixedDiscount(Money.of(15)); order.applyDiscount(discount); expect(order.total).toEqual(Money.of(85)); }); it("should cap discount at order subtotal", () => { const order = createOrderWithSubtotal(Money.of(10)); const discount = createFixedDiscount(Money.of(50)); // More than order value order.applyDiscount(discount); expect(order.total).toEqual(Money.of(0)); // Not negative }); it("should reject expired discount", () => { const order = createOrderWithSubtotal(Money.of(100)); const expiredDiscount = createExpiredDiscount(); expect(() => order.applyDiscount(expiredDiscount)) .toThrow(ExpiredDiscountError); }); it("should enforce minimum order value", () => { const order = createOrderWithSubtotal(Money.of(25)); const discount = createDiscountWithMinimum(Money.of(50)); expect(() => order.applyDiscount(discount)) .toThrow(MinimumNotMetError); }); });});Test Data Builders and Object Mothers:
Domain tests require constructing complex objects in specific states. Using raw constructors leads to verbose, brittle tests. Instead, use Test Data Builders or Object Mothers to create readable, maintainable test fixtures.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687
// Test Data Builder pattern - fluent interface for test objectsclass OrderBuilder { private order: Partial<OrderData> = { id: generateId(), status: OrderStatus.DRAFT, items: [], createdAt: new Date(), }; static anOrder(): OrderBuilder { return new OrderBuilder(); } withId(id: string): this { this.order.id = id; return this; } withStatus(status: OrderStatus): this { this.order.status = status; return this; } withItems(items: OrderItem[]): this { this.order.items = items; return this; } withItem(product: Product, quantity: number): this { this.order.items = [ ...(this.order.items || []), new OrderItem(product.id, product.price, quantity), ]; return this; } withSubtotal(amount: Money): this { // Create enough items to reach the subtotal this.order.items = [new OrderItem("test-product", amount, 1)]; return this; } confirmed(): this { this.order.status = OrderStatus.CONFIRMED; this.order.confirmedAt = new Date(); return this; } build(): Order { return Order.fromData(this.order as OrderData); }} // Usage in tests - highly readabledescribe("Order cancellation", () => { it("should allow cancellation within grace period", () => { const order = OrderBuilder.anOrder() .confirmed() .withItems([item1, item2]) .build(); // ...test logic });}); // Object Mother pattern - pre-built common scenariosclass OrderMother { static draftOrderWithTwoItems(): Order { return OrderBuilder.anOrder() .withItem(ProductMother.aBook(), 1) .withItem(ProductMother.aGadget(), 2) .build(); } static confirmedOrderWorthOver100(): Order { return OrderBuilder.anOrder() .withSubtotal(Money.of(150)) .confirmed() .build(); } static expiredOrder(): Order { return OrderBuilder.anOrder() .withStatus(OrderStatus.EXPIRED) .build(); }}API handlers are the entry points to your microservice—they receive HTTP requests, coordinate service logic, and return responses. Unit testing handlers requires a careful balance: you want to verify the request/response contract without testing the underlying business logic (which has its own unit tests).
What API Handler Unit Tests Should Verify:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
// Express handler with proper separation of concernsclass OrderController { constructor( private readonly orderService: OrderService, private readonly authService: AuthService ) {} async createOrder(req: Request, res: Response, next: NextFunction) { try { // Parse and validate request const createOrderDto = CreateOrderSchema.parse(req.body); const userId = req.user!.id; // Set by auth middleware // Delegate to service const order = await this.orderService.createOrder(userId, createOrderDto); // Return response res.status(201).json({ success: true, data: OrderResponseDto.fromDomain(order), }); } catch (error) { next(error); // Let error middleware handle it } } async getOrder(req: Request, res: Response, next: NextFunction) { try { const orderId = req.params.orderId; const userId = req.user!.id; const order = await this.orderService.getOrder(orderId); // Authorization check if (order.userId !== userId && !req.user!.isAdmin) { throw new ForbiddenError("Cannot access order belonging to another user"); } res.json({ success: true, data: OrderResponseDto.fromDomain(order), }); } catch (error) { if (error instanceof OrderNotFoundError) { res.status(404).json({ success: false, error: "Order not found" }); } else { next(error); } } }} // Unit tests for controller - service is mockeddescribe("OrderController", () => { let controller: OrderController; let mockOrderService: jest.Mocked<OrderService>; let mockAuthService: jest.Mocked<AuthService>; let mockRequest: Partial<Request>; let mockResponse: Partial<Response>; let mockNext: jest.Mock; beforeEach(() => { mockOrderService = { createOrder: jest.fn(), getOrder: jest.fn(), } as any; mockAuthService = {} as any; controller = new OrderController(mockOrderService, mockAuthService); mockResponse = { status: jest.fn().mockReturnThis(), json: jest.fn(), }; mockNext = jest.fn(); }); describe("createOrder", () => { it("should return 201 with created order on success", async () => { const createDto = { items: [{ productId: "p1", quantity: 2 }] }; const createdOrder = OrderMother.draftOrderWithTwoItems(); mockOrderService.createOrder.mockResolvedValue(createdOrder); mockRequest = { body: createDto, user: { id: "user-123" }, }; await controller.createOrder( mockRequest as Request, mockResponse as Response, mockNext ); expect(mockResponse.status).toHaveBeenCalledWith(201); expect(mockResponse.json).toHaveBeenCalledWith({ success: true, data: expect.objectContaining({ id: createdOrder.id, }), }); }); it("should pass validation errors to error middleware", async () => { mockRequest = { body: { items: [] }, // Invalid - empty items user: { id: "user-123" }, }; await controller.createOrder( mockRequest as Request, mockResponse as Response, mockNext ); expect(mockNext).toHaveBeenCalledWith(expect.any(z.ZodError)); }); it("should call service with correct parameters", async () => { const createDto = { items: [{ productId: "p1", quantity: 2 }] }; mockOrderService.createOrder.mockResolvedValue(OrderMother.draftOrderWithTwoItems()); mockRequest = { body: createDto, user: { id: "user-456" }, }; await controller.createOrder( mockRequest as Request, mockResponse as Response, mockNext ); expect(mockOrderService.createOrder).toHaveBeenCalledWith("user-456", createDto); }); }); describe("getOrder", () => { it("should return 404 when order not found", async () => { mockOrderService.getOrder.mockRejectedValue(new OrderNotFoundError("order-999")); mockRequest = { params: { orderId: "order-999" }, user: { id: "user-123", isAdmin: false }, }; await controller.getOrder( mockRequest as Request, mockResponse as Response, mockNext ); expect(mockResponse.status).toHaveBeenCalledWith(404); expect(mockResponse.json).toHaveBeenCalledWith({ success: false, error: "Order not found", }); }); it("should return 403 when accessing another user's order", async () => { const otherUsersOrder = OrderBuilder.anOrder() .withId("order-123") .build(); Object.assign(otherUsersOrder, { userId: "other-user" }); mockOrderService.getOrder.mockResolvedValue(otherUsersOrder); mockRequest = { params: { orderId: "order-123" }, user: { id: "current-user", isAdmin: false }, }; await controller.getOrder( mockRequest as Request, mockResponse as Response, mockNext ); expect(mockNext).toHaveBeenCalledWith(expect.any(ForbiddenError)); }); it("should allow admin to access any order", async () => { const otherUsersOrder = OrderBuilder.anOrder().build(); Object.assign(otherUsersOrder, { userId: "other-user" }); mockOrderService.getOrder.mockResolvedValue(otherUsersOrder); mockRequest = { params: { orderId: "order-123" }, user: { id: "admin-user", isAdmin: true }, }; await controller.getOrder( mockRequest as Request, mockResponse as Response, mockNext ); expect(mockResponse.json).toHaveBeenCalledWith( expect.objectContaining({ success: true }) ); }); });});API handler unit tests should focus on the HTTP contract: 'Given this request, expect this response.' Avoid testing internal wiring like 'verify the controller called the service with these exact arguments'—such tests break with refactoring and don't add value beyond the contract tests.
In event-driven microservices, message handlers are as important as API handlers—they receive events from other services and trigger internal processing. Unit testing these handlers requires mocking the message infrastructure while verifying the handler's behavior.
Key Testing Concerns for Event Handlers:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
// Event handler with proper error handling and idempotencyclass PaymentCompletedHandler { constructor( private readonly orderRepository: OrderRepository, private readonly eventPublisher: EventPublisher, private readonly idempotencyStore: IdempotencyStore, private readonly logger: Logger ) {} async handle(event: PaymentCompletedEvent): Promise<void> { // Idempotency check const idempotencyKey = `payment-completed:${event.paymentId}`; if (await this.idempotencyStore.exists(idempotencyKey)) { this.logger.info("Duplicate event ignored", { paymentId: event.paymentId }); return; } // Find and update order const order = await this.orderRepository.findById(event.orderId); if (!order) { this.logger.warn("Order not found for payment", { orderId: event.orderId, paymentId: event.paymentId }); throw new OrderNotFoundError(event.orderId); } // Process the payment confirmation order.confirmPayment(event.paymentId, event.amount); await this.orderRepository.save(order); // Publish downstream events await this.eventPublisher.publish(new OrderPaidEvent({ orderId: order.id, paymentId: event.paymentId, paidAt: event.completedAt, })); // Mark as processed await this.idempotencyStore.mark(idempotencyKey, { processedAt: new Date(), orderId: order.id }); }} // Comprehensive unit testsdescribe("PaymentCompletedHandler", () => { let handler: PaymentCompletedHandler; let mockOrderRepository: jest.Mocked<OrderRepository>; let mockEventPublisher: jest.Mocked<EventPublisher>; let mockIdempotencyStore: jest.Mocked<IdempotencyStore>; let mockLogger: jest.Mocked<Logger>; beforeEach(() => { mockOrderRepository = { findById: jest.fn(), save: jest.fn(), } as any; mockEventPublisher = { publish: jest.fn() }; mockIdempotencyStore = { exists: jest.fn().mockResolvedValue(false), mark: jest.fn() }; mockLogger = { info: jest.fn(), warn: jest.fn() } as any; handler = new PaymentCompletedHandler( mockOrderRepository, mockEventPublisher, mockIdempotencyStore, mockLogger ); }); it("should update order status and publish OrderPaidEvent", async () => { const order = OrderBuilder.anOrder().withStatus(OrderStatus.PENDING).build(); const event = new PaymentCompletedEvent({ paymentId: "pay-123", orderId: order.id, amount: Money.of(99.99), completedAt: new Date(), }); mockOrderRepository.findById.mockResolvedValue(order); await handler.handle(event); expect(mockOrderRepository.save).toHaveBeenCalledWith( expect.objectContaining({ status: OrderStatus.PAID }) ); expect(mockEventPublisher.publish).toHaveBeenCalledWith( expect.objectContaining({ type: "OrderPaidEvent", data: expect.objectContaining({ orderId: order.id }), }) ); }); it("should ignore duplicate events", async () => { mockIdempotencyStore.exists.mockResolvedValue(true); const event = createPaymentCompletedEvent(); await handler.handle(event); expect(mockOrderRepository.findById).not.toHaveBeenCalled(); expect(mockEventPublisher.publish).not.toHaveBeenCalled(); expect(mockLogger.info).toHaveBeenCalledWith( "Duplicate event ignored", expect.any(Object) ); }); it("should throw when order not found", async () => { mockOrderRepository.findById.mockResolvedValue(null); const event = createPaymentCompletedEvent({ orderId: "nonexistent" }); await expect(handler.handle(event)).rejects.toThrow(OrderNotFoundError); expect(mockEventPublisher.publish).not.toHaveBeenCalled(); expect(mockIdempotencyStore.mark).not.toHaveBeenCalled(); }); it("should mark event as processed after success", async () => { const order = createPendingOrder(); mockOrderRepository.findById.mockResolvedValue(order); const event = createPaymentCompletedEvent({ paymentId: "pay-456", orderId: order.id }); await handler.handle(event); expect(mockIdempotencyStore.mark).toHaveBeenCalledWith( "payment-completed:pay-456", expect.objectContaining({ orderId: order.id }) ); }); it("should not mark event as processed on failure", async () => { mockOrderRepository.findById.mockRejectedValue(new DatabaseError("Connection lost")); const event = createPaymentCompletedEvent(); await expect(handler.handle(event)).rejects.toThrow(DatabaseError); expect(mockIdempotencyStore.mark).not.toHaveBeenCalled(); });});A well-organized test suite is as important as well-organized production code. As services evolve, poorly structured tests become a maintenance burden that slows development rather than enabling it.
Organizing Test Files:
Follow the mirror structure convention—test files mirror the source structure:
src/
orders/
order.ts
order-service.ts
handlers/
create-order-handler.ts
test/
orders/
order.test.ts
order-service.test.ts
handlers/
create-order-handler.test.ts
Alternatively, co-locate tests with source files for easier navigation:
src/
orders/
order.ts
order.test.ts
order-service.ts
order-service.test.ts
Both approaches work; consistency within a codebase matters most.
should_apply_discount_when_coupon_is_valid > test1given_expired_session_when_accessing_resource_then_returns_4011234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
// Well-organized test file following AAA patterndescribe("OrderService", () => { // Group by feature, not by method describe("Order Creation", () => { describe("with valid input", () => { it("should create order with correct initial status", () => { // Arrange const service = createOrderService(); const input = validOrderInput(); // Act const order = service.createOrder(input); // Assert expect(order.status).toBe(OrderStatus.DRAFT); }); it("should calculate subtotal from items", () => { // Arrange const service = createOrderService(); const input = orderInputWith([ { productId: "p1", price: 10, quantity: 2 }, { productId: "p2", price: 5, quantity: 3 }, ]); // Act const order = service.createOrder(input); // Assert expect(order.subtotal).toEqual(Money.of(35)); }); }); describe("with invalid input", () => { it.each([ { field: "items", value: [], error: "Order must have at least one item" }, { field: "items", value: null, error: "Items are required" }, { field: "customerId", value: "", error: "Customer ID is required" }, ])("should reject when $field is $value", ({ field, value, error }) => { const service = createOrderService(); const input = { ...validOrderInput(), [field]: value }; expect(() => service.createOrder(input)).toThrow(error); }); }); }); describe("Order Confirmation", () => { it("should transition status from DRAFT to CONFIRMED", () => { const service = createOrderService(); const order = createDraftOrder(); service.confirmOrder(order.id); expect(order.status).toBe(OrderStatus.CONFIRMED); }); it("should reject confirmation of already confirmed order", () => { const service = createOrderService(); const order = createConfirmedOrder(); expect(() => service.confirmOrder(order.id)) .toThrow("Order is already confirmed"); }); });}); // Shared test utilities in separate file// test/helpers/order-helpers.tsexport function createOrderService(overrides: Partial<OrderServiceDependencies> = {}) { return new OrderService({ orderRepository: new InMemoryOrderRepository(), eventPublisher: new NoOpEventPublisher(), ...overrides, });} export function validOrderInput(): CreateOrderInput { return { customerId: "cust-123", items: [{ productId: "prod-1", quantity: 1 }], };}Avoid these patterns that make tests hard to maintain:
• Shared mutable state between tests: Each test should set up its own fixtures • Magic numbers/strings: Use named constants or builders • Over-mocking: If setup is complex, the code may need refactoring • Testing implementation details: Focus on public interfaces and behavior • Copy-paste test code: Extract common setup into helpers
Unit testing in microservices is both an art and a discipline. When done well, it creates a safety net that enables rapid, confident evolution of individual services. When done poorly, it becomes a maintenance burden that slows development without providing proportional value.
What's Next:
Unit tests verify that individual components work correctly in isolation, but microservices must interact with databases, caches, message brokers, and other external systems. The next page explores Integration Testing—how to verify that your service correctly integrates with its infrastructure dependencies, catching the bugs that unit tests with mocked dependencies would miss.
You now understand how to design and implement effective unit testing strategies for microservices. You've learned about test doubles, domain logic testing, API and event handler testing, and test organization. Next, we'll explore how to verify service behavior with real infrastructure through integration testing.