Loading content...
Resource management code is uniquely treacherous. Unlike typical application logic where bugs manifest as incorrect outputs or visible errors, resource management failures are often silent. A file handle left open doesn't crash your application—it quietly consumes system resources until, hours or days later, the operating system refuses to open any more files. A database connection that isn't returned to the pool doesn't throw an exception—it silently depletes your connection pool until your application grinds to a halt under load.
This silence makes testing resource management not just important, but critical. Without deliberate verification that resources are being properly acquired and released, you're building on a foundation of hope rather than engineering certainty. The question isn't whether your untested resource management code will fail—it's when, and how catastrophically.
By the end of this page, you will understand how to design testable resource management code, implement verification strategies for cleanup operations, write tests that catch resource leaks before production, and apply patterns that make resource lifecycle assertions straightforward and reliable.
Testing resource cleanup presents unique challenges that standard unit testing approaches don't address. Understanding these challenges is the first step toward developing effective testing strategies.
Why resource cleanup is hard to test:
Resource cleanup operates at the intersection of your application code and external systems—operating systems, databases, network stacks, and memory managers. This boundary crossing creates several problems:
file.close(), there's no return value indicating success. The operation's effect—releasing the OS file handle—is a side effect on an external system that your test can't directly observe.A passing test suite doesn't guarantee correct resource management. Tests typically run with fresh processes, minimal load, and generous timeouts. Resource leaks may only manifest after hundreds of operations or under concurrent access—conditions your test suite might never create.
The fundamental insight:
To test resource cleanup effectively, we must transform the invisible into the visible. We need to structure our code so that cleanup operations produce observable evidence—evidence our tests can verify.
The key to effective resource cleanup testing is designing your resource management code with testability as a first-class concern. This doesn't mean compromising production code quality—rather, it means structuring code so that its correctness can be verified.
Principle 1: Make cleanup observable
The most fundamental principle is transforming silent cleanup into observable behavior. There are several techniques to achieve this:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
// Pattern 1: Cleanup callbacks for test observationinterface ResourceWithCallback { dispose(onCleanup?: () => void): void;} class FileResource implements ResourceWithCallback { private isOpen = true; private readonly handle: FileHandle; constructor(path: string) { this.handle = openFile(path); } dispose(onCleanup?: () => void): void { if (this.isOpen) { this.handle.close(); this.isOpen = false; onCleanup?.(); // Test hook: observable cleanup } } // For testing: expose internal state get isClosed(): boolean { return !this.isOpen; }} // Pattern 2: Disposal tracking registryclass ResourceRegistry { private acquired = new Set<string>(); private disposed = new Set<string>(); trackAcquisition(resourceId: string): void { this.acquired.add(resourceId); } trackDisposal(resourceId: string): void { this.disposed.add(resourceId); } // Test assertion: all acquired resources were disposed assertAllDisposed(): void { const leaks = [...this.acquired].filter(id => !this.disposed.has(id)); if (leaks.length > 0) { throw new Error(`Resource leaks detected: ${leaks.join(', ')}`); } } reset(): void { this.acquired.clear(); this.disposed.clear(); }}Principle 2: Inject resource factories
Direct resource acquisition (new FileStream(path), DriverManager.getConnection()) creates hard dependencies that resist testing. Instead, inject factories that can be replaced with test doubles:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
// Injectable factory pattern for testabilityinterface IConnectionFactory { createConnection(): IConnection;} interface IConnection { execute(query: string): Promise<Result>; dispose(): void;} // Production implementationclass DatabaseConnectionFactory implements IConnectionFactory { constructor(private readonly connectionString: string) {} createConnection(): IConnection { return new RealDatabaseConnection(this.connectionString); }} // Service that uses connectionsclass DataRepository { constructor(private readonly connectionFactory: IConnectionFactory) {} async fetchData(id: string): Promise<Data> { const connection = this.connectionFactory.createConnection(); try { return await connection.execute(`SELECT * FROM data WHERE id = '${id}'`); } finally { connection.dispose(); // Cleanup happens regardless of success/failure } }} // Test: verify cleanup with mock factoryclass MockConnectionFactory implements IConnectionFactory { readonly connections: MockConnection[] = []; createConnection(): IConnection { const conn = new MockConnection(); this.connections.push(conn); return conn; } assertAllConnectionsClosed(): void { const unclosed = this.connections.filter(c => !c.isClosed); if (unclosed.length > 0) { throw new Error(`${unclosed.length} connection(s) not properly closed`); } }} class MockConnection implements IConnection { isClosed = false; async execute(query: string): Promise<Result> { return { rows: [] }; } dispose(): void { this.isClosed = true; }}Testable resource management follows the same principle as other testable code: invert control. Instead of classes creating their own resources, they receive factories they can use to request resources. This allows tests to substitute observable implementations that track and verify cleanup behavior.
With testable patterns in place, we can write tests that verify resource cleanup actually occurs. Let's explore the key testing scenarios and techniques.
Scenario 1: Happy path cleanup verification
The simplest case: verify that normal operation properly releases resources.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; describe('DataRepository resource cleanup', () => { let mockFactory: MockConnectionFactory; let repository: DataRepository; beforeEach(() => { mockFactory = new MockConnectionFactory(); repository = new DataRepository(mockFactory); }); afterEach(() => { // Verify no leaks after each test mockFactory.assertAllConnectionsClosed(); }); it('should close connection after successful query', async () => { // Arrange const id = 'test-id-123'; // Act await repository.fetchData(id); // Assert expect(mockFactory.connections).toHaveLength(1); expect(mockFactory.connections[0].isClosed).toBe(true); }); it('should close connection even when query fails', async () => { // Arrange mockFactory = new MockConnectionFactory(); mockFactory.failNextQuery = true; // Configure to throw repository = new DataRepository(mockFactory); // Act & Assert await expect(repository.fetchData('any-id')).rejects.toThrow(); // Critical: verify cleanup still happened expect(mockFactory.connections[0].isClosed).toBe(true); }); it('should handle multiple sequential operations', async () => { // Act await repository.fetchData('id-1'); await repository.fetchData('id-2'); await repository.fetchData('id-3'); // Assert: each operation should use and close its own connection expect(mockFactory.connections).toHaveLength(3); expect(mockFactory.connections.every(c => c.isClosed)).toBe(true); });});Scenario 2: Testing cleanup ordering
When multiple resources must be cleaned up, the order often matters. For example, you must close a transaction before closing the connection that owns it:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
// Testing that cleanup happens in correct orderclass OrderTrackingResource { private static cleanupOrder: string[] = []; constructor(private readonly name: string) {} dispose(): void { OrderTrackingResource.cleanupOrder.push(this.name); } static getCleanupOrder(): string[] { return [...OrderTrackingResource.cleanupOrder]; } static resetTracking(): void { OrderTrackingResource.cleanupOrder = []; }} describe('nested resource cleanup ordering', () => { beforeEach(() => { OrderTrackingResource.resetTracking(); }); it('should clean up inner resources before outer resources', async () => { // Simulate: connection -> transaction -> cursor // Cleanup should be: cursor -> transaction -> connection const connection = new OrderTrackingResource('connection'); const transaction = new OrderTrackingResource('transaction'); const cursor = new OrderTrackingResource('cursor'); // Business operations... // Cleanup in reverse order of acquisition cursor.dispose(); transaction.dispose(); connection.dispose(); // Verify order expect(OrderTrackingResource.getCleanupOrder()).toEqual([ 'cursor', 'transaction', 'connection' ]); }); it('should maintain cleanup order even with exceptions', async () => { const resources: OrderTrackingResource[] = []; try { resources.push(new OrderTrackingResource('outer')); resources.push(new OrderTrackingResource('middle')); resources.push(new OrderTrackingResource('inner')); throw new Error('Simulated failure'); } finally { // Cleanup in reverse order while (resources.length > 0) { resources.pop()!.dispose(); } } expect(OrderTrackingResource.getCleanupOrder()).toEqual([ 'inner', 'middle', 'outer' ]); });});Resources should typically be cleaned up in Last-In-First-Out (LIFO) order—the reverse of their acquisition order. This ensures that resources don't try to use their dependencies after those dependencies have been disposed. Your tests should verify this ordering is maintained.
Cleanup callbacks are a powerful pattern for verifying resource disposal in tests. They allow you to inject observation points into the cleanup process without modifying the core resource logic.
Pattern: Verified disposal with callbacks
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
// Resource supporting cleanup callbacksinterface DisposableResource { dispose(): Promise<void>; onDispose(callback: () => void): void;} class ManagedFileHandle implements DisposableResource { private readonly callbacks: (() => void)[] = []; private disposed = false; constructor(private readonly path: string) {} onDispose(callback: () => void): void { this.callbacks.push(callback); } async dispose(): Promise<void> { if (this.disposed) return; // Idempotent await this.closeHandle(); this.disposed = true; // Notify all observers this.callbacks.forEach(cb => cb()); } private async closeHandle(): Promise<void> { // Actual file closing logic }} // Test using callbacksdescribe('ManagedFileHandle disposal', () => { it('should invoke cleanup callbacks on dispose', async () => { const handle = new ManagedFileHandle('/tmp/test.txt'); let cleanupInvoked = false; handle.onDispose(() => { cleanupInvoked = true; }); await handle.dispose(); expect(cleanupInvoked).toBe(true); }); it('should invoke multiple callbacks in registration order', async () => { const handle = new ManagedFileHandle('/tmp/test.txt'); const invocationOrder: number[] = []; handle.onDispose(() => invocationOrder.push(1)); handle.onDispose(() => invocationOrder.push(2)); handle.onDispose(() => invocationOrder.push(3)); await handle.dispose(); expect(invocationOrder).toEqual([1, 2, 3]); }); it('should handle idempotent disposal gracefully', async () => { const handle = new ManagedFileHandle('/tmp/test.txt'); let callbackCount = 0; handle.onDispose(() => callbackCount++); // Dispose multiple times await handle.dispose(); await handle.dispose(); await handle.dispose(); // Callback should only fire once expect(callbackCount).toBe(1); });});Modern resources often involve asynchronous cleanup—flushing buffers, closing network connections, or waiting for pending operations. Your test framework must support async assertions. Use async/await in tests and ensure cleanup callbacks are awaited before making assertions.
Some resource cleanup is best verified by examining state changes. This approach is particularly useful when cleanup has observable effects on the resource's internal state or on external systems.
Pattern: Exposing disposal state
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
// Pattern: Resource with queryable disposal stateenum ResourceState { Created = 'CREATED', Active = 'ACTIVE', Disposing = 'DISPOSING', Disposed = 'DISPOSED'} class StatefulResource { private _state: ResourceState = ResourceState.Created; get state(): ResourceState { return this._state; } get isActive(): boolean { return this._state === ResourceState.Active; } get isDisposed(): boolean { return this._state === ResourceState.Disposed; } async activate(): Promise<void> { this.assertState(ResourceState.Created); // Acquisition logic... this._state = ResourceState.Active; } async dispose(): Promise<void> { if (this._state === ResourceState.Disposed) return; if (this._state === ResourceState.Disposing) { throw new Error('Dispose already in progress'); } this._state = ResourceState.Disposing; try { await this.performCleanup(); this._state = ResourceState.Disposed; } catch (error) { // Cleanup failed - still mark as disposed to prevent reuse this._state = ResourceState.Disposed; throw error; } } private assertState(expected: ResourceState): void { if (this._state !== expected) { throw new Error( `Invalid state transition: expected ${expected}, was ${this._state}` ); } } private async performCleanup(): Promise<void> { // Actual cleanup logic }} // State-based testsdescribe('StatefulResource state transitions', () => { it('should transition through correct states', async () => { const resource = new StatefulResource(); expect(resource.state).toBe(ResourceState.Created); await resource.activate(); expect(resource.state).toBe(ResourceState.Active); expect(resource.isActive).toBe(true); await resource.dispose(); expect(resource.state).toBe(ResourceState.Disposed); expect(resource.isDisposed).toBe(true); }); it('should prevent operations on disposed resource', async () => { const resource = new StatefulResource(); await resource.activate(); await resource.dispose(); // Attempting to activate disposed resource should fail await expect(resource.activate()).rejects.toThrow('Invalid state'); }); it('should mark as disposed even if cleanup fails', async () => { const resource = new StatefulResourceWithFailingCleanup(); await resource.activate(); await expect(resource.dispose()).rejects.toThrow(); // Despite failure, resource should be marked disposed // to prevent reuse of potentially corrupted resource expect(resource.isDisposed).toBe(true); });});The most critical cleanup verification tests are those that verify resources are released even when operations fail. This is where most resource leaks originate—developers test the happy path but forget about exception flows.
The cleanup guarantee principle:
Resources must be released regardless of how the operation using them terminates. This includes normal completion, exceptions, timeouts, and cancellation.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
describe('cleanup under failure conditions', () => { let resourceTracker: ResourceTracker; beforeEach(() => { resourceTracker = new ResourceTracker(); }); afterEach(() => { resourceTracker.assertNoLeaks(); }); it('should cleanup when operation throws synchronously', async () => { const service = new FailingService(resourceTracker); await expect(service.operationThatThrowsImmediately()) .rejects.toThrow('Immediate failure'); expect(resourceTracker.openResources).toBe(0); }); it('should cleanup when operation throws asynchronously', async () => { const service = new FailingService(resourceTracker); await expect(service.operationThatThrowsAsync()) .rejects.toThrow('Async failure'); expect(resourceTracker.openResources).toBe(0); }); it('should cleanup when operation times out', async () => { const service = new SlowService(resourceTracker); const timeoutPromise = withTimeout( service.verySlowOperation(), 100 // ms ); await expect(timeoutPromise).rejects.toThrow('Operation timed out'); // Critical: resource should still be cleaned up // even though the operation didn't complete normally await waitForCleanup(50); // Allow cleanup to run expect(resourceTracker.openResources).toBe(0); }); it('should cleanup when operation is cancelled', async () => { const controller = new AbortController(); const service = new CancellableService(resourceTracker); const operationPromise = service.cancellableOperation(controller.signal); // Cancel mid-operation setTimeout(() => controller.abort(), 50); await expect(operationPromise).rejects.toThrow('Operation cancelled'); expect(resourceTracker.openResources).toBe(0); }); it('should cleanup all resources when nested operation fails', async () => { const service = new NestedOperationService(resourceTracker); // This operation acquires 3 resources, then fails on the 3rd await expect(service.nestedOperationFailingOnThird()) .rejects.toThrow(); // All 3 resources should be cleaned up expect(resourceTracker.totalAcquired).toBe(3); expect(resourceTracker.totalReleased).toBe(3); expect(resourceTracker.openResources).toBe(0); });}); // Helper: timeout wrapperfunction withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> { return Promise.race([ promise, new Promise<never>((_, reject) => setTimeout(() => reject(new Error('Operation timed out')), ms) ) ]);} // Helper: wait for async cleanupfunction waitForCleanup(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms));}Timeouts are particularly dangerous for resource management. When you abort an operation due to timeout, the underlying operation may still be running asynchronously—with resources allocated. Your cleanup logic must account for this: either the operation must be truly cancellable, or you must track and clean up 'orphaned' resources from timed-out operations.
A well-designed dispose method should be idempotent—calling it multiple times should have the same effect as calling it once, and should not throw errors. This is critical for robust error handling, where cleanup code may run multiple times through different paths.
Why idempotency matters:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
describe('idempotent disposal behavior', () => { it('should allow multiple dispose calls without error', async () => { const resource = new ManagedResource(); await resource.activate(); // Should not throw await resource.dispose(); await resource.dispose(); await resource.dispose(); expect(resource.isDisposed).toBe(true); }); it('should only perform cleanup once', async () => { const cleanupCounter = { count: 0 }; const resource = new ManagedResource({ onCleanup: () => cleanupCounter.count++ }); await resource.activate(); await resource.dispose(); await resource.dispose(); await resource.dispose(); // Cleanup logic should only run once expect(cleanupCounter.count).toBe(1); }); it('should handle concurrent dispose calls', async () => { const resource = new ManagedResource(); await resource.activate(); // Call dispose concurrently const disposePromises = [ resource.dispose(), resource.dispose(), resource.dispose(), ]; // All should resolve without error await expect(Promise.all(disposePromises)).resolves.toBeDefined(); expect(resource.isDisposed).toBe(true); }); it('should return immediately if already disposed', async () => { const resource = new SlowCleanupResource(); await resource.activate(); // First dispose is slow const firstStart = Date.now(); await resource.dispose(); const firstDuration = Date.now() - firstStart; // Second dispose should be instant const secondStart = Date.now(); await resource.dispose(); const secondDuration = Date.now() - secondStart; expect(secondDuration).toBeLessThan(firstDuration / 10); });}); // Implementation pattern for idempotent disposalclass ManagedResource { private disposed = false; private disposePromise: Promise<void> | null = null; private options: { onCleanup?: () => void }; constructor(options: { onCleanup?: () => void } = {}) { this.options = options; } get isDisposed(): boolean { return this.disposed; } async activate(): Promise<void> { /* ... */ } async dispose(): Promise<void> { // Fast path: already disposed if (this.disposed) return; // Prevent concurrent cleanup if (this.disposePromise) { return this.disposePromise; } // Perform cleanup once this.disposePromise = this.performCleanup(); await this.disposePromise; this.disposed = true; } private async performCleanup(): Promise<void> { // Actual cleanup work this.options.onCleanup?.(); }}The pattern shown uses a cached promise to handle concurrent dispose calls. The first caller creates the promise and stores it; subsequent callers simply await the existing promise. This ensures cleanup runs exactly once, even with concurrent calls.
Verifying resource cleanup is one of the most important—and often neglected—aspects of testing resource management code. Let's consolidate the key principles and techniques we've covered:
What's next:
Verifying cleanup is half the battle—the other half is detecting when cleanup doesn't happen. In the next page, we'll explore techniques for detecting resource leaks, including memory profiling, handle tracking, and automated leak detection in test suites.
You now understand how to design testable resource management code and write tests that verify cleanup occurs correctly. These patterns form the foundation for building robust, leak-free systems. Next, we'll explore how to detect resource leaks when prevention fails.