Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
87 changes: 86 additions & 1 deletion src/nestjs/app/testing.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -33,6 +38,19 @@ class TestModule {}
})
class AppModule {}

@Module({ controllers: [TestController] })
class UnresolvableModule {}

@Injectable()
class FailingOnInitService implements OnModuleInit {
async onModuleInit(): Promise<void> {
throw new Error('🔥');
}
}

@Module({ providers: [FailingOnInitService] })
class FailingOnInitModule {}

class Fixture1 implements Fixture {
async init(): Promise<NestJsModuleOverrider> {
return (builder) =>
Expand All @@ -50,6 +68,14 @@ class Fixture2 implements Fixture {
async delete() {}
}

class FailingFixture implements Fixture {
async init(): Promise<undefined> {
throw new Error('💥');
}
async clear() {}
async delete() {}
}

describe('AppFixture', () => {
let appFixture!: AppFixture;

Expand Down Expand Up @@ -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', () => {
Expand Down
94 changes: 69 additions & 25 deletions src/nestjs/app/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

/**
Expand Down Expand Up @@ -200,49 +206,83 @@ 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<void> {
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<void>((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<void>((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;
}
}

/**
* Clears all fixtures.
* This is useful to reset the state of the application between tests.
*/
async clear(): Promise<void> {
if (this.state === AppFixtureState.Failed) {
return;
}

if (this.state !== AppFixtureState.Active) {
throw new Error('Cannot clear fixtures that are not active.');
}
Expand All @@ -260,6 +300,10 @@ export class AppFixture {
* Closes the application and deletes all fixtures.
*/
async delete(): Promise<void> {
if (this.state === AppFixtureState.Failed) {
return;
}

if (this.state !== AppFixtureState.Active) {
throw new Error('Cannot delete fixtures that are not active.');
}
Expand Down