Loading learning content...
Testing resource management code presents a fundamental paradox: you want to verify that your code correctly handles files, databases, and network connections—but using real resources makes tests slow, flaky, and hard to control.
Consider the challenges of testing with real resources:
The solution is mocking—replacing real resources with controlled substitutes that simulate resource behavior while remaining fast, reliable, and deterministic. But mocking resources correctly requires understanding both the resource abstraction patterns and the specific verification needs for resource management testing.
By the end of this page, you will understand how to design resource abstractions that are mockable, implement mock resources that verify cleanup behavior, create test doubles for files, databases, and network resources, and use mocking frameworks effectively for resource management testing.
Effective resource mocking follows specific principles that ensure your mocks accurately represent real resource behavior while providing the observability needed for verification.
Principle 1: Mock at the right abstraction level
Choosing where to mock is crucial. Mock too low and your tests become brittle, tightly coupled to implementation details. Mock too high and you don't actually test your resource management code.
| Level | Example | Pros | Cons |
|---|---|---|---|
| OS/Runtime API | Mock fs.open | Complete control | Brittle, implementation-coupled |
| Library/SDK | Mock database driver | Good isolation | Driver-specific |
| Repository/Gateway | Mock IUserRepository | Tests business logic | Skips resource management code |
| Resource Wrapper | Mock IFileResource | Tests cleanup patterns | Best for resource management tests |
Principle 2: Mocks must be disposable
For resource management testing, your mocks themselves must implement the disposal pattern. This allows tests to verify that disposal is called and behaves correctly:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// WRONG: Mock that doesn't track disposalclass BadMockConnection { execute(query: string): Promise<Result> { return Promise.resolve({ rows: [] }); } // No dispose method - can't verify cleanup!} // CORRECT: Mock with full lifecycle trackingclass MockConnection implements IConnection { private _isOpen = true; private _disposed = false; private _disposeCount = 0; readonly queries: string[] = []; get isOpen(): boolean { return this._isOpen; } get isDisposed(): boolean { return this._disposed; } get disposeCallCount(): number { return this._disposeCount; } async execute(query: string): Promise<Result> { if (this._disposed) { throw new Error('Cannot execute on disposed connection'); } this.queries.push(query); return { rows: [] }; } dispose(): void { this._disposeCount++; if (!this._disposed) { this._isOpen = false; this._disposed = true; } } // Test helper: verify proper usage assertProperlyManaged(): void { if (!this._disposed) { throw new Error('Connection was not disposed'); } if (this._disposeCount > 1) { // This might be okay (idempotent) or might indicate a bug console.warn(`dispose() called ${this._disposeCount} times`); } }}Principle 3: Mocks should expose verification points
Mocks for resource management testing need to expose internal state and history that real resources don't. This isn't a violation of encapsulation—it's the purpose of the mock:
A mock isn't just a stand-in for a real resource—it's a testing instrument. Design your mocks with the same care you'd design your real implementations, but optimize for testability rather than production performance.
File system operations are among the most commonly mocked resources. A good file system mock must handle file handles, content, paths, and cleanup semantics.
Pattern: In-memory file system mock
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
// Comprehensive in-memory file system mock for testinginterface IFileHandle { read(): Promise<Buffer>; write(data: Buffer): Promise<void>; close(): Promise<void>; readonly path: string; readonly isOpen: boolean;} interface IFileSystem { open(path: string, mode: 'r' | 'w' | 'rw'): Promise<IFileHandle>; exists(path: string): Promise<boolean>; delete(path: string): Promise<void>; readdir(path: string): Promise<string[]>;} class MockFileHandle implements IFileHandle { private _isOpen = true; private _closeCount = 0; constructor( readonly path: string, private readonly fs: MockFileSystem, private readonly mode: 'r' | 'w' | 'rw' ) { fs.trackHandleOpened(this); } get isOpen(): boolean { return this._isOpen; } get closeCount(): number { return this._closeCount; } async read(): Promise<Buffer> { this.ensureOpen('read'); if (this.mode === 'w') { throw new Error('Cannot read from write-only handle'); } return this.fs.getFileContent(this.path); } async write(data: Buffer): Promise<void> { this.ensureOpen('write'); if (this.mode === 'r') { throw new Error('Cannot write to read-only handle'); } this.fs.setFileContent(this.path, data); } async close(): Promise<void> { this._closeCount++; if (this._isOpen) { this._isOpen = false; this.fs.trackHandleClosed(this); } } private ensureOpen(operation: string): void { if (!this._isOpen) { throw new Error(`Cannot ${operation} closed file handle: ${this.path}`); } }} class MockFileSystem implements IFileSystem { private files = new Map<string, Buffer>(); private openHandles = new Set<MockFileHandle>(); private allHandlesEverOpened: MockFileHandle[] = []; // Configure initial file state setFile(path: string, content: string | Buffer): void { this.files.set(path, typeof content === 'string' ? Buffer.from(content) : content ); } getFileContent(path: string): Buffer { const content = this.files.get(path); if (!content) throw new Error(`File not found: ${path}`); return content; } setFileContent(path: string, content: Buffer): void { this.files.set(path, content); } async open(path: string, mode: 'r' | 'w' | 'rw'): Promise<IFileHandle> { if (mode === 'r' && !this.files.has(path)) { throw new Error(`ENOENT: File not found: ${path}`); } return new MockFileHandle(path, this, mode); } async exists(path: string): Promise<boolean> { return this.files.has(path); } async delete(path: string): Promise<void> { this.files.delete(path); } async readdir(path: string): Promise<string[]> { const prefix = path.endsWith('/') ? path : path + '/'; return Array.from(this.files.keys()) .filter(p => p.startsWith(prefix)) .map(p => p.substring(prefix.length).split('/')[0]); } // Internal tracking for handles trackHandleOpened(handle: MockFileHandle): void { this.openHandles.add(handle); this.allHandlesEverOpened.push(handle); } trackHandleClosed(handle: MockFileHandle): void { this.openHandles.delete(handle); } // ===== TEST VERIFICATION METHODS ===== getOpenHandleCount(): number { return this.openHandles.size; } getOpenHandles(): MockFileHandle[] { return Array.from(this.openHandles); } assertAllHandlesClosed(): void { if (this.openHandles.size > 0) { const leaked = Array.from(this.openHandles) .map(h => h.path) .join(', '); throw new Error(`File handle leak: ${this.openHandles.size} handles still open: ${leaked}`); } } assertHandleWasClosed(path: string): void { const handle = this.allHandlesEverOpened.find(h => h.path === path); if (!handle) { throw new Error(`No handle was ever opened for: ${path}`); } if (handle.isOpen) { throw new Error(`Handle for ${path} was opened but never closed`); } } reset(): void { this.files.clear(); this.openHandles.clear(); this.allHandlesEverOpened = []; }}Using the mock in tests:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
describe('FileProcessor with mock file system', () => { let mockFs: MockFileSystem; let processor: FileProcessor; beforeEach(() => { mockFs = new MockFileSystem(); processor = new FileProcessor(mockFs); }); afterEach(() => { // Verify no handle leaks after every test mockFs.assertAllHandlesClosed(); }); it('should close file handle after successful processing', async () => { mockFs.setFile('/input/data.txt', 'test content'); await processor.processFile('/input/data.txt'); expect(mockFs.getOpenHandleCount()).toBe(0); mockFs.assertHandleWasClosed('/input/data.txt'); }); it('should close file handle even when processing throws', async () => { mockFs.setFile('/input/bad.txt', 'invalid content'); await expect(processor.processFile('/input/bad.txt')) .rejects.toThrow('Processing failed'); // Critical: handle must still be closed expect(mockFs.getOpenHandleCount()).toBe(0); }); it('should handle multiple files without leaking handles', async () => { mockFs.setFile('/a.txt', 'a'); mockFs.setFile('/b.txt', 'b'); mockFs.setFile('/c.txt', 'c'); await processor.processFiles(['/a.txt', '/b.txt', '/c.txt']); expect(mockFs.getOpenHandleCount()).toBe(0); }); it('should prevent operations on closed handles', async () => { mockFs.setFile('/test.txt', 'content'); const handle = await mockFs.open('/test.txt', 'r'); await handle.close(); await expect(handle.read()) .rejects.toThrow('Cannot read closed file handle'); });});Database connection mocks must handle connection pooling semantics, transaction lifecycle, and the various failure modes that can occur with real databases.
Pattern: Mock connection pool with behavior configuration
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
// Behavior configuration for mock connectioninterface MockConnectionBehavior { executeDelay?: number; // Simulate network latency failOnExecute?: boolean; // Simulate query failure failOnOpen?: boolean; // Simulate connection failure failOnClose?: boolean; // Simulate cleanup failure throwAfterQueries?: number; // Fail after N queries} class MockDatabaseConnection implements IConnection { private _isOpen = true; private _isDisposed = false; private _queryCount = 0; private _inTransaction = false; private readonly _executedQueries: string[] = []; constructor( readonly id: string, private readonly behavior: MockConnectionBehavior = {} ) {} get isOpen(): boolean { return this._isOpen; } get isDisposed(): boolean { return this._isDisposed; } get queryCount(): number { return this._queryCount; } get executedQueries(): string[] { return [...this._executedQueries]; } get inTransaction(): boolean { return this._inTransaction; } async execute(query: string): Promise<QueryResult> { this.ensureOpen(); if (this.behavior.executeDelay) { await sleep(this.behavior.executeDelay); } this._queryCount++; this._executedQueries.push(query); if (this.behavior.throwAfterQueries && this._queryCount >= this.behavior.throwAfterQueries) { throw new Error(`Mock failure after ${this._queryCount} queries`); } if (this.behavior.failOnExecute) { throw new Error('Mock execute failure'); } return { rows: [], affectedRows: 0 }; } async beginTransaction(): Promise<void> { this.ensureOpen(); if (this._inTransaction) { throw new Error('Transaction already in progress'); } this._inTransaction = true; this._executedQueries.push('BEGIN TRANSACTION'); } async commit(): Promise<void> { this.ensureOpen(); if (!this._inTransaction) { throw new Error('No transaction to commit'); } this._inTransaction = false; this._executedQueries.push('COMMIT'); } async rollback(): Promise<void> { this.ensureOpen(); if (!this._inTransaction) { throw new Error('No transaction to rollback'); } this._inTransaction = false; this._executedQueries.push('ROLLBACK'); } dispose(): void { if (this.behavior.failOnClose) { throw new Error('Mock dispose failure'); } if (!this._isDisposed) { // Auto-rollback uncommitted transaction (like real DBs) if (this._inTransaction) { this._executedQueries.push('ROLLBACK (auto on dispose)'); this._inTransaction = false; } this._isOpen = false; this._isDisposed = true; } } private ensureOpen(): void { if (!this._isOpen || this._isDisposed) { throw new Error('Connection is closed'); } } // Verification helpers assertDisposed(): void { if (!this._isDisposed) { throw new Error(`Connection ${this.id} was not disposed`); } } assertNoUncommittedTransaction(): void { if (this._inTransaction) { throw new Error(`Connection ${this.id} has uncommitted transaction`); } } assertQueryExecuted(pattern: string | RegExp): void { const found = this._executedQueries.some(q => typeof pattern === 'string' ? q.includes(pattern) : pattern.test(q) ); if (!found) { throw new Error( `Expected query matching "${pattern}" not found. Executed: ${this._executedQueries.join('; ')}` ); } }} // Mock connection poolclass MockConnectionPool implements IConnectionPool { private connections: MockDatabaseConnection[] = []; private _borrowCount = 0; private _returnCount = 0; private nextBehavior: MockConnectionBehavior = {}; borrow(): MockDatabaseConnection { this._borrowCount++; const conn = new MockDatabaseConnection( `conn-${this._borrowCount}`, this.nextBehavior ); this.connections.push(conn); return conn; } return(conn: IConnection): void { this._returnCount++; (conn as MockDatabaseConnection).dispose(); } // Test configuration setNextBehavior(behavior: MockConnectionBehavior): void { this.nextBehavior = behavior; } // Verification get borrowCount(): number { return this._borrowCount; } get returnCount(): number { return this._returnCount; } get allConnections(): MockDatabaseConnection[] { return [...this.connections]; } assertAllReturned(): void { if (this._borrowCount !== this._returnCount) { throw new Error( `Connection leak: ${this._borrowCount} borrowed, ${this._returnCount} returned` ); } } assertAllDisposed(): void { const undisposed = this.connections.filter(c => !c.isDisposed); if (undisposed.length > 0) { throw new Error( `${undisposed.length} connection(s) not disposed: ${undisposed.map(c => c.id).join(', ')}` ); } }}Testing transaction cleanup with mocks:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
describe('TransactionManager cleanup behavior', () => { let mockPool: MockConnectionPool; let txManager: TransactionManager; beforeEach(() => { mockPool = new MockConnectionPool(); txManager = new TransactionManager(mockPool); }); afterEach(() => { mockPool.assertAllReturned(); mockPool.assertAllDisposed(); }); it('should commit and return connection on success', async () => { await txManager.executeInTransaction(async (conn) => { await conn.execute('INSERT INTO users ...'); }); const conn = mockPool.allConnections[0]; expect(conn.executedQueries).toContain('COMMIT'); expect(conn.isDisposed).toBe(true); }); it('should rollback and return connection on failure', async () => { await expect(txManager.executeInTransaction(async (conn) => { await conn.execute('INSERT INTO users ...'); throw new Error('Business logic failed'); })).rejects.toThrow('Business logic failed'); const conn = mockPool.allConnections[0]; expect(conn.executedQueries).toContain('ROLLBACK'); expect(conn.isDisposed).toBe(true); }); it('should handle query failure mid-transaction', async () => { mockPool.setNextBehavior({ throwAfterQueries: 2 }); await expect(txManager.executeInTransaction(async (conn) => { await conn.execute('INSERT 1'); // OK await conn.execute('INSERT 2'); // Throws })).rejects.toThrow('Mock failure'); const conn = mockPool.allConnections[0]; expect(conn.executedQueries).toContain('ROLLBACK'); }); it('should cleanup even if close fails', async () => { mockPool.setNextBehavior({ failOnClose: true }); // Should not throw despite close failure await expect(txManager.executeInTransaction(async (conn) => { await conn.execute('SELECT 1'); })).rejects.toThrow(); // Or resolves, depending on your error policy // Connection was at least attempted to be cleaned up expect(mockPool.returnCount).toBe(1); });});Real resource management bugs often appear in error paths—the code that runs when things go wrong. Mock behavior configuration lets you simulate failures at specific points and verify that cleanup still occurs. Don't just test the happy path.
Network resources—HTTP clients, WebSockets, gRPC channels—have unique lifecycle considerations including connection reuse, timeouts, and graceful shutdown.
Pattern: Mock HTTP client with resource tracking
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
// Mock HTTP client with connection and stream managementinterface HttpResponse { status: number; headers: Record<string, string>; body: () => Promise<Buffer>; bodyStream: () => ReadableStream<Uint8Array>;} interface IHttpClient { request(options: RequestOptions): Promise<HttpResponse>; close(): Promise<void>;} class MockHttpClient implements IHttpClient { private _isClosed = false; private _requestCount = 0; private _activeStreams = new Set<MockBodyStream>(); private readonly _requestLog: RequestOptions[] = []; private responseHandlers = new Map<string, () => HttpResponse>(); // Configure responses onRequest(urlPattern: string, handler: () => HttpResponse): void { this.responseHandlers.set(urlPattern, handler); } async request(options: RequestOptions): Promise<HttpResponse> { if (this._isClosed) { throw new Error('Cannot use closed HTTP client'); } this._requestCount++; this._requestLog.push(options); // Find matching handler const handler = Array.from(this.responseHandlers.entries()) .find(([pattern]) => options.url.includes(pattern))?.[1]; if (handler) { const response = handler(); // Wrap body stream to track it return this.wrapResponseWithTracking(response); } // Default response return this.wrapResponseWithTracking({ status: 200, headers: {}, body: async () => Buffer.from(''), bodyStream: () => new ReadableStream(), }); } private wrapResponseWithTracking(response: HttpResponse): HttpResponse { const self = this; return { ...response, bodyStream: () => { const stream = new MockBodyStream(response, self); self._activeStreams.add(stream); return stream; }, }; } streamClosed(stream: MockBodyStream): void { this._activeStreams.delete(stream); } async close(): Promise<void> { // Abort any active streams for (const stream of this._activeStreams) { stream.abort(); } this._activeStreams.clear(); this._isClosed = true; } // Verification get isClosed(): boolean { return this._isClosed; } get requestCount(): number { return this._requestCount; } get requestLog(): RequestOptions[] { return [...this._requestLog]; } get activeStreamCount(): number { return this._activeStreams.size; } assertClosed(): void { if (!this._isClosed) { throw new Error('HTTP client was not closed'); } } assertNoActiveStreams(): void { if (this._activeStreams.size > 0) { throw new Error(`${this._activeStreams.size} response stream(s) still active`); } } assertRequestMade(urlPattern: string | RegExp): void { const found = this._requestLog.some(r => typeof urlPattern === 'string' ? r.url.includes(urlPattern) : urlPattern.test(r.url) ); if (!found) { throw new Error(`No request matching "${urlPattern}" was made`); } }} class MockBodyStream extends ReadableStream<Uint8Array> { private _aborted = false; private _closed = false; constructor( private readonly response: HttpResponse, private readonly client: MockHttpClient ) { super({ start(controller) { // Simulate some data response.body().then(data => { controller.enqueue(new Uint8Array(data)); controller.close(); }); }, }); } abort(): void { this._aborted = true; this.cancel(); } // Override to track closure async cancel(reason?: any): Promise<void> { this._closed = true; this.client.streamClosed(this); return super.cancel(reason); }}Testing streaming response cleanup:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
describe('ApiClient stream management', () => { let mockHttp: MockHttpClient; let apiClient: ApiClient; beforeEach(() => { mockHttp = new MockHttpClient(); apiClient = new ApiClient(mockHttp); mockHttp.onRequest('/stream', () => ({ status: 200, headers: { 'content-type': 'application/octet-stream' }, body: async () => Buffer.from('chunk1chunk2chunk3'), bodyStream: () => new ReadableStream(), })); }); afterEach(async () => { await apiClient.close(); mockHttp.assertClosed(); mockHttp.assertNoActiveStreams(); }); it('should close stream after successful read', async () => { const data = await apiClient.downloadFile('/stream/file.dat'); expect(mockHttp.activeStreamCount).toBe(0); }); it('should close stream if processing fails mid-stream', async () => { // Configure to throw during processing apiClient.setProcessChunk(() => { throw new Error('Process failure'); }); await expect(apiClient.downloadFile('/stream/file.dat')) .rejects.toThrow('Process failure'); // Stream must still be closed expect(mockHttp.activeStreamCount).toBe(0); }); it('should close underlying client on shutdown', async () => { await apiClient.downloadFile('/stream/file1.dat'); await apiClient.downloadFile('/stream/file2.dat'); await apiClient.close(); mockHttp.assertClosed(); }); it('should abort active streams on forced close', async () => { // Start a download but don't wait for it const downloadPromise = apiClient.downloadLargeFile('/stream/huge.dat'); // Immediately close await apiClient.close(); // Download should fail gracefully await expect(downloadPromise).rejects.toThrow(); expect(mockHttp.activeStreamCount).toBe(0); });});Different types of test doubles serve different purposes in resource management testing. Understanding when to use each type is crucial.
The Test Double Spectrum:
| Type | Purpose | Resource Testing Use Case | Example |
|---|---|---|---|
| Dummy | Satisfy type requirements | Unused resource parameter | Empty logger passed to constructor |
| Stub | Return predefined values | Simulate resource states | Always returns open/closed state |
| Spy | Record interactions | Track cleanup calls | Records when dispose() called |
| Mock | Verify expected interactions | Assert cleanup occurred | Fails if dispose() not called |
| Fake | Simplified working implementation | In-memory database | SQLite instead of PostgreSQL |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687
// DUMMY: Just satisfies the interface, no behaviorclass DummyConnection implements IConnection { execute(_: string): Promise<Result> { throw new Error('Not implemented'); } dispose(): void { /* nothing */ }} // STUB: Returns predefined valuesclass StubConnection implements IConnection { private results: Result[] = []; setResults(...results: Result[]): void { this.results = results; } async execute(_: string): Promise<Result> { return this.results.shift() ?? { rows: [] }; } dispose(): void { /* nothing */ }} // SPY: Records what happenedclass SpyConnection implements IConnection { readonly executedQueries: string[] = []; disposeCallCount = 0; async execute(query: string): Promise<Result> { this.executedQueries.push(query); return { rows: [] }; } dispose(): void { this.disposeCallCount++; }} // MOCK: Has expectations that can failclass MockConnection implements IConnection { private expectDispose = false; private disposeCalledCount = 0; expectDisposeCall(): void { this.expectDispose = true; } async execute(_: string): Promise<Result> { return { rows: [] }; } dispose(): void { this.disposeCalledCount++; } verify(): void { if (this.expectDispose && this.disposeCalledCount === 0) { throw new Error('Expected dispose() to be called, but it was not'); } }} // FAKE: Working simplified implementationclass FakeInMemoryDatabase implements IDatabase { private tables = new Map<string, any[]>(); private connectionPool: FakeConnection[] = []; createTable(name: string): void { this.tables.set(name, []); } createConnection(): IConnection { const conn = new FakeConnection(this.tables); this.connectionPool.push(conn); return conn; } // Can verify actual state of fake getTableData(name: string): any[] { return this.tables.get(name) ?? []; } assertAllConnectionsClosed(): void { const open = this.connectionPool.filter(c => !c.isClosed); if (open.length > 0) { throw new Error(`${open.length} connections not closed`); } }}For complex resources with state (like databases), fakes are often more useful than mocks. A fake in-memory database can verify both behavior (cleanup) and outcome (data was actually written). Mocks only verify the behavior was called, not that it was correct.
Mocking frameworks provide powerful tools for creating test doubles with minimal boilerplate. However, they require careful use for resource management testing.
Framework-based mocks for cleanup verification:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; describe('resource cleanup with vitest mocks', () => { // Create mock factory that returns mocks with spy capabilities const mockConnectionFactory = { createConnection: vi.fn(() => ({ execute: vi.fn().mockResolvedValue({ rows: [] }), dispose: vi.fn(), // Track state for verification _isOpen: true, })), }; beforeEach(() => { vi.clearAllMocks(); }); it('should call dispose on connections', async () => { const repository = new UserRepository(mockConnectionFactory); await repository.findById('123'); // Get the mock connection that was created const mockConnection = mockConnectionFactory.createConnection.mock.results[0].value; // Verify dispose was called expect(mockConnection.dispose).toHaveBeenCalledTimes(1); }); it('should dispose in correct order for nested resources', async () => { const callOrder: string[] = []; const mockInnerResource = { use: vi.fn(), dispose: vi.fn(() => callOrder.push('inner')), }; const mockOuterResource = { inner: mockInnerResource, dispose: vi.fn(() => callOrder.push('outer')), }; // Simulate using nested resources await useNestedResources(mockOuterResource); // Verify LIFO order expect(callOrder).toEqual(['inner', 'outer']); }); it('should handle async dispose', async () => { const mockAsyncResource = { use: vi.fn(), dispose: vi.fn().mockImplementation(async () => { await new Promise(r => setTimeout(r, 10)); }), }; await withResource(mockAsyncResource, async (r) => { r.use(); }); // Verify async dispose was awaited expect(mockAsyncResource.dispose).toHaveBeenCalledTimes(1); });});Mocking frameworks create mocks that don't have real internal state—they just record what methods were called. This means you can't check if a mock is 'really' disposed in the same way you would with a hand-written mock. Use explicit state tracking when it matters.
Effective resource mocking requires discipline and patterns. Here are the key best practices to follow:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
// Template for well-designed resource mockabstract class BaseMockResource<TConfig = {}> { protected _disposed = false; protected _disposeCount = 0; protected _operationLog: string[] = []; constructor(protected config: TConfig = {} as TConfig) {} // State accessors get isDisposed(): boolean { return this._disposed; } get disposeCount(): number { return this._disposeCount; } get operationLog(): string[] { return [...this._operationLog]; } // Lifecycle dispose(): void { this._disposeCount++; if (!this._disposed) { this._disposed = true; this._operationLog.push('dispose'); this.onDispose(); } } protected abstract onDispose(): void; // Verification helpers - call these in tests assertDisposed(): void { if (!this._disposed) { throw new Error(`${this.constructor.name} was not disposed`); } } assertDisposeCalledOnce(): void { if (this._disposeCount !== 1) { throw new Error( `Expected dispose() once, called ${this._disposeCount} times` ); } } assertOperationSequence(expected: string[]): void { const actual = this._operationLog; if (JSON.stringify(actual) !== JSON.stringify(expected)) { throw new Error( `Operation sequence mismatch.\nExpected: ${expected.join(' → ')}\nActual: ${actual.join(' → ')}` ); } } // For test reset reset(): void { this._disposed = false; this._disposeCount = 0; this._operationLog = []; }} // Example concrete mock using the templateclass MockFileResource extends BaseMockResource<{ failOnRead?: boolean }> { async read(): Promise<string> { if (this._disposed) throw new Error('Cannot read disposed file'); this._operationLog.push('read'); if (this.config.failOnRead) throw new Error('Mock read failure'); return 'mock content'; } protected onDispose(): void { // Custom cleanup logic for this resource type }}Mocking external resources is essential for testing resource management code effectively. Let's consolidate the key principles and patterns we've covered:
What's next:
Unit tests with mocks verify that individual components manage resources correctly. But real applications compose many components together, and resource management bugs often emerge at integration boundaries. In the final page of this module, we'll explore integration testing strategies for resource management—testing that resources flow correctly across component boundaries and throughout your system.
You now understand how to design and implement mocks for external resources that enable thorough testing of resource management code. These mocking patterns let you test cleanup behavior, failure handling, and lifecycle management without the overhead and unpredictability of real resources.