From f0223505310f02138b06fcdb65307101c962b86c Mon Sep 17 00:00:00 2001 From: Flo Date: Fri, 31 Jul 2026 15:57:00 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20Clean=20up=20resources=20whe?= =?UTF-8?q?n=20the=20`AppFixture`=20fails=20to=20initialize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/nestjs/app/testing.spec.ts | 87 ++++++++++++++++++++++++++++++- src/nestjs/app/testing.ts | 94 +++++++++++++++++++++++++--------- 2 files changed, 155 insertions(+), 26 deletions(-) diff --git a/src/nestjs/app/testing.spec.ts b/src/nestjs/app/testing.spec.ts index a567f93..1d6c44e 100644 --- a/src/nestjs/app/testing.spec.ts +++ b/src/nestjs/app/testing.spec.ts @@ -1,5 +1,10 @@ import { jest } from '@jest/globals'; -import { Controller, Injectable, Module } from '@nestjs/common'; +import { + Controller, + Injectable, + Module, + type OnModuleInit, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { LoggingFixture } from '../testing.js'; import { ConfigFixture } from './config-fixture.js'; @@ -33,6 +38,19 @@ class TestModule {} }) class AppModule {} +@Module({ controllers: [TestController] }) +class UnresolvableModule {} + +@Injectable() +class FailingOnInitService implements OnModuleInit { + async onModuleInit(): Promise { + throw new Error('🔥'); + } +} + +@Module({ providers: [FailingOnInitService] }) +class FailingOnInitModule {} + class Fixture1 implements Fixture { async init(): Promise { return (builder) => @@ -50,6 +68,14 @@ class Fixture2 implements Fixture { async delete() {} } +class FailingFixture implements Fixture { + async init(): Promise { + throw new Error('💥'); + } + async clear() {} + async delete() {} +} + describe('AppFixture', () => { let appFixture!: AppFixture; @@ -240,6 +266,65 @@ describe('AppFixture', () => { 'Cannot initialize the application more than once.', ); }); + + it('should delete the initialized fixtures and rethrow when a fixture fails to initialize', async () => { + const fixture1 = new Fixture1(); + const failingFixture = new FailingFixture(); + jest.spyOn(fixture1, 'delete'); + jest.spyOn(failingFixture, 'delete'); + appFixture = new AppFixture(AppModule, { + fixtures: [fixture1, failingFixture], + }); + + const actualPromise = appFixture.init(); + + await expect(actualPromise).rejects.toThrow('💥'); + expect(fixture1.delete).toHaveBeenCalledOnce(); + expect(failingFixture.delete).not.toHaveBeenCalled(); + }); + + it('should delete the fixtures and rethrow when the module fails to compile', async () => { + const fixture2 = new Fixture2(); + jest.spyOn(fixture2, 'delete'); + appFixture = new AppFixture(UnresolvableModule, { fixtures: [fixture2] }); + + const actualPromise = appFixture.init(); + + await expect(actualPromise).rejects.toThrow(/Nest can't resolve/); + expect(fixture2.delete).toHaveBeenCalledOnce(); + }); + + it('should close the application and delete the fixtures when the application fails to initialize', async () => { + const fixture2 = new Fixture2(); + jest.spyOn(fixture2, 'delete'); + appFixture = new AppFixture(FailingOnInitModule, { + fixtures: [fixture2], + }); + + const actualPromise = appFixture.init(); + + await expect(actualPromise).rejects.toThrow('🔥'); + expect(fixture2.delete).toHaveBeenCalledOnce(); + expect(appFixture.app).toBeUndefined(); + expect(appFixture.request).toBeUndefined(); + }); + + it('should make clear and delete no-ops after a failed initialization', async () => { + const fixture1 = new Fixture1(); + jest.spyOn(fixture1, 'clear'); + jest.spyOn(fixture1, 'delete'); + appFixture = new AppFixture(AppModule, { + fixtures: [fixture1, new FailingFixture()], + }); + await expect(appFixture.init()).rejects.toThrow('💥'); + (fixture1.delete as jest.Mock).mockClear(); + + await appFixture.clear(); + await appFixture.delete(); + + expect(fixture1.clear).not.toHaveBeenCalled(); + expect(fixture1.delete).not.toHaveBeenCalled(); + }); }); describe('clear', () => { diff --git a/src/nestjs/app/testing.ts b/src/nestjs/app/testing.ts index 53551ff..e350b84 100644 --- a/src/nestjs/app/testing.ts +++ b/src/nestjs/app/testing.ts @@ -61,6 +61,12 @@ enum AppFixtureState { * The app fixture has been deleted, meaning the application has been closed and all fixtures have been deleted. */ Deleted, + + /** + * The initialization of the app fixture failed. Any resource allocated before the failure has been released as part + * of {@link AppFixture.init}, which means {@link AppFixture.clear} and {@link AppFixture.delete} have nothing to do. + */ + Failed, } /** @@ -200,42 +206,72 @@ export class AppFixture { /** * Initializes the application and all fixtures. * This should only be called once, before tests. + * + * If initialization fails, any resource allocated before the failure is released before the original error is + * rethrown. The fixture then permanently transitions to a failed state, where {@link AppFixture.clear} and + * {@link AppFixture.delete} are no-ops. */ async init(): Promise { if (this.state !== AppFixtureState.Uninitialized) { throw new Error('Cannot initialize the application more than once.'); } - this.state = AppFixtureState.Active; - let builder = Test.createTestingModule({ - imports: [createAppModule(this.appModule)], - }); + // The fixtures that did initialize, and which must be deleted during cleanup if the initialization fails. + let initializedFixtures: Fixture[] = []; + let app: INestApplication | undefined; - const overrides = await Promise.all(this.fixtures.map((f) => f.init(this))); - [...overrides, this.override] - .filter((o) => !!o) - .forEach((o) => (builder = o(builder))); + try { + let builder = Test.createTestingModule({ + imports: [createAppModule(this.appModule)], + }); - const moduleRef = await builder.compile(); + const results = await Promise.allSettled( + this.fixtures.map((fixture) => fixture.init(this)), + ); + initializedFixtures = this.fixtures.filter( + (_, i) => results[i].status === 'fulfilled', + ); - (this as any).app = moduleRef.createNestApplication( - this.nestApplicationOptions, - ); - await this.app.init(); - - // The server is explicitly started on the loopback address, such that `supertest` does not bind it itself. When it - // does, it listens on the IPv6 wildcard address, which can conflict with an unrelated process listening on - // `127.0.0.1` with the same port. Requests would then be routed to that process instead of the application. - await new Promise((resolve, reject) => { - const server = this.app.getHttpServer(); - server.once('error', reject); - server.listen(0, '127.0.0.1', () => { - server.removeListener('error', reject); - resolve(); + const failure = results.find((r) => r.status === 'rejected'); + if (failure) { + throw failure.reason; + } + + const overrides = results + .filter((r) => r.status === 'fulfilled') + .map((r) => r.value); + [...overrides, this.override] + .filter((o) => !!o) + .forEach((o) => (builder = o(builder))); + + const moduleRef = await builder.compile(); + + app = moduleRef.createNestApplication(this.nestApplicationOptions); + await app.init(); + + // The server is explicitly started on the loopback address, such that `supertest` does not bind it itself. + // When it does, it listens on the IPv6 wildcard address, which can conflict with an unrelated process listening + // on `127.0.0.1` with the same port. Requests would then be routed to that process instead of the application. + const server = app.getHttpServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.removeListener('error', reject); + resolve(); + }); }); - }); - (this as any).request = supertest(this.app.getHttpServer()); + (this as any).app = app; + (this as any).request = supertest(server); + this.state = AppFixtureState.Active; + } catch (error) { + this.state = AppFixtureState.Failed; + + await app?.close().catch(() => {}); + await Promise.allSettled(initializedFixtures.map((f) => f.delete())); + + throw error; + } } /** @@ -243,6 +279,10 @@ export class AppFixture { * This is useful to reset the state of the application between tests. */ async clear(): Promise { + if (this.state === AppFixtureState.Failed) { + return; + } + if (this.state !== AppFixtureState.Active) { throw new Error('Cannot clear fixtures that are not active.'); } @@ -260,6 +300,10 @@ export class AppFixture { * Closes the application and deletes all fixtures. */ async delete(): Promise { + if (this.state === AppFixtureState.Failed) { + return; + } + if (this.state !== AppFixtureState.Active) { throw new Error('Cannot delete fixtures that are not active.'); } From 98ad80119f861cbd1c97fd98e813bb0b9e7d2ba8 Mon Sep 17 00:00:00 2001 From: Flo Date: Fri, 31 Jul 2026 15:58:18 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=9D=20Update=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c37b80d..e5aa87e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +Fixes: + +- Make the `AppFixture` release the resources it has allocated when its initialization fails. + ## v2.0.1 (2026-07-29) Fixes: