diff --git a/docs/guides/dependency-inversion.md b/docs/guides/dependency-inversion.md new file mode 100644 index 0000000..e6b021d --- /dev/null +++ b/docs/guides/dependency-inversion.md @@ -0,0 +1,228 @@ +--- +sidebar_position: 5 +title: Dependency Inversion +description: Using .mock().final() with token-based dependency injection +--- + +# Dependency Inversion + +> **What this covers:** Using `.mock().final()` with token-based dependency injection (symbols, strings) \ +> **Time to read:** ~10 minutes \ +> **Prerequisites:** [Sociable Unit Tests](./sociable), [Mock Configuration](/docs/api-reference/mock-configuration) \ +> **Best for:** Working with Inversify, custom DI containers, or any framework using symbol/string tokens + +When using dependency injection frameworks that rely on **token-based injection** (symbols or strings instead of class constructors), you cannot use `.expose()` to provide real implementations. Instead, use `.mock().final()` to configure concrete implementations for these tokens. + +## The Problem with Token-Based Injection + +Many dependency injection frameworks use tokens (symbols or strings) to identify dependencies rather than class constructors. Examples include: + +- **InversifyJS**: Uses `Symbol` tokens +- **Custom DI containers**: Often use string tokens +- **NestJS**: Can use string tokens via `@Inject('TOKEN')` + +When you try to use `.expose()` with a token, Suites cannot determine what concrete implementation to use: + +```typescript +// ❌ This doesn't work with token-based injection +const TYPES = { + UserRepository: Symbol.for("UserRepository"), +}; + +const { unit } = await TestBed.sociable(UserService) + .expose(TYPES.UserRepository) // Error: Suites can't determine the concrete class + .compile(); +``` + +The issue is that a token (symbol or string) doesn't carry information about the concrete implementation class. Suites needs to know which class to instantiate. + +## Solution: Use `.mock().final()` + +Instead of `.expose()`, use `.mock().final()` to provide the concrete implementation for token-based dependencies. + +### When to Use `.mock().final()` + +Use `.mock().final()` when: + +- You want a fixed implementation that won't change across tests +- You don't need to retrieve or modify the mock after compilation +- You're providing configuration objects or simple implementations + +## Example: InversifyJS with Symbol Tokens + +This example shows how to test a service that uses InversifyJS with symbol-based tokens. + +### 1. Define Tokens and Interfaces + +```typescript title="src/types.ts" +export const TYPES = { + UserRepository: Symbol.for("UserRepository"), + EmailService: Symbol.for("EmailService"), + Database: Symbol.for("Database"), +}; + +export interface UserRepository { + findById(id: number): Promise; + save(user: User): Promise; +} + +export interface EmailService { + send(to: string, subject: string, body: string): Promise; +} + +export interface Database { + connect(): Promise; + query(sql: string): Promise; +} +``` + +### 2. Implement Concrete Classes + +```typescript title="src/repositories/user.repository.ts" +import { injectable } from "inversify"; +import { TYPES, UserRepository, User } from "../types"; + +@injectable() +export class UserRepositoryImpl implements UserRepository { + async findById(id: number): Promise { + // Real implementation + return { id, name: "John Doe" }; + } + + async save(user: User): Promise { + // Real implementation + return { ...user, id: Date.now() }; + } +} +``` + +```typescript title="src/services/user.service.ts" +import { injectable, inject } from "inversify"; +import { TYPES, UserRepository, EmailService } from "../types"; + +@injectable() +export class UserService { + constructor( + @inject(TYPES.UserRepository) private userRepo: UserRepository, + @inject(TYPES.EmailService) private emailService: EmailService + ) {} + + async createUser(name: string, email: string): Promise { + const user = await this.userRepo.save({ name, email }); + await this.emailService.send(email, "Welcome", `Hello ${name}!`); + return user; + } +} +``` + +### 3. Test with `.mock().final()` + +Use `.mock().final()` to provide a fixed implementation that behaves consistently across all tests: + +```typescript title="src/services/user.service.spec.ts" +import { TestBed } from "@suites/unit"; +import { UserService } from "./user.service"; +import { UserRepositoryImpl } from "../repositories/user.repository"; +import { TYPES, UserRepository, EmailService } from "../types"; + +describe("UserService with .mock().final()", () => { + let userService: UserService; + + beforeAll(async () => { + const { unit } = await TestBed.solitary(UserService) + // Provide concrete implementation for symbol token + .mock(TYPES.UserRepository) + .final({ + findById: async (id: number) => ({ id, name: "Test User" }), + save: async (user: Omit) => ({ ...user, id: 123 }), + }) + // Email service stays mocked (default behavior) + .compile(); + + userService = unit; + }); + + it("creates user with fixed repository behavior", async () => { + const user = await userService.createUser("Alice", "alice@example.com"); + + expect(user.id).toBe(123); + expect(user.name).toBe("Alice"); + }); +}); +``` + +## Example: String Tokens + +The same approach works with string tokens: + +```typescript title="src/services/payment.service.ts" +import { Injectable, Inject } from "@nestjs/common"; + +@Injectable() +export class PaymentService { + constructor( + @Inject("STRIPE_API") private stripe: StripeApi, + @Inject("PAYMENT_CONFIG") private config: PaymentConfig + ) {} + + async processPayment(amount: number): Promise { + return this.stripe.charge(amount, this.config.currency); + } +} +``` + +```typescript title="src/services/payment.service.spec.ts" +import { TestBed } from "@suites/unit"; +import { PaymentService } from "./payment.service"; +import { StripeApiImpl } from "../integrations/stripe-api"; + +describe("PaymentService with string tokens", () => { + let paymentService: PaymentService; + + beforeAll(async () => { + const { unit } = await TestBed.solitary(PaymentService) + .mock("STRIPE_API") + .final({ + charge: async (amount: number, currency: string) => ({ + id: "ch_123", + amount, + currency, + status: "succeeded", + }), + }) + .mock("PAYMENT_CONFIG") + .final({ currency: "USD", apiKey: "test-key" }) + .compile(); + + paymentService = unit; + }); + + it("processes payment with configured mocks", async () => { + const result = await paymentService.processPayment(100); + + expect(result.id).toBe("ch_123"); + expect(result.amount).toBe(100); + expect(result.currency).toBe("USD"); + }); +}); +``` + +## Comparison: `.expose()` vs `.mock().final()` + +| Method | Works with Class Tokens | Works with Symbol/String Tokens | Retrievable via `unitRef` | Use Case | +| ----------------- | ----------------------- | ------------------------------- | ------------------------- | ------------------------------------ | +| `.expose()` | ✅ Yes | ❌ No | ❌ No | Real implementation, class-based DI | +| `.mock().final()` | ✅ Yes | ✅ Yes | ❌ No | Fixed behavior, configuration values | + +## Key Takeaways + +1. **Token-based injection** (symbols/strings) cannot use `.expose()` because Suites can't determine the concrete class +2. **Use `.mock().final()`** for fixed implementations that don't need modification +3. **`.mock().final()` works** with class constructors, symbols, and strings + +## Related Guides + +- [Sociable Unit Tests](./sociable) - Testing with real dependencies +- [Mock Configuration](/docs/api-reference/mock-configuration) - Detailed API reference +- [Test Doubles](./test-doubles) - Understanding mocks and stubs +- [Virtual Test Container](./virtual-test-container) - How Suites handles token injection diff --git a/docs/guides/index.md b/docs/guides/index.md index e0ca6be..66dd3b1 100644 --- a/docs/guides/index.md +++ b/docs/guides/index.md @@ -17,6 +17,7 @@ Practical guides for writing solitary and sociable unit tests with Suites. Learn - [**Fundamentals of Unit Testing**](/docs/guides/fundamentals) - Core principles and best practices - [**Solitary Unit Tests**](/docs/guides/solitary) - Testing components in complete isolation - [**Sociable Unit Tests**](/docs/guides/sociable) - Testing with real dependencies +- [**Mocking Dependency Inversion**](/docs/guides/dependency-inversion) - Using .mock().final() and .mock().impl() with token-based DI - [**Test Doubles**](/docs/guides/test-doubles) - Mocks, stubs, and spies - [**Virtual Test Container**](/docs/guides/virtual-test-container) - How Suites creates test environments diff --git a/docs/guides/sociable.md b/docs/guides/sociable.md index ed7705c..f74f02e 100644 --- a/docs/guides/sociable.md +++ b/docs/guides/sociable.md @@ -91,6 +91,10 @@ describe('UserService', () => { ``` The real `EmailValidator` runs its actual validation logic. If the validator has a bug, this test will detect it. +:::note Token-Based Injection Limitation +`.expose()` only works with **class constructors**. If you're using token-based dependency injection (symbols or strings like InversifyJS), you cannot use `.expose()`. Instead, use [`.mock().final()` or `.mock().impl()`](/docs/guides/dependency-inversion) to provide concrete implementations for tokens. +::: + ## Step 2: Handle External Dependencies Most services interact with external systems like databases. This example extends the previous one. @@ -325,5 +329,6 @@ Sociable tests work alongside solitary tests to provide comprehensive coverage: After understanding sociable testing, explore these resources: +- **[Mocking Dependency Inversion](./dependency-inversion)**: Using `.mock().final()` and `.mock().impl()` with token-based DI (symbols, strings) - **[Test Doubles](./test-doubles)**: Core concepts of mocking and stubbing - **[Suites Examples Repository](https://github.com/suites-dev/examples)**: Working examples of sociable testing patterns