Here, we take a look at how to test Angular applications that rely on the inject function to resolve dependencies.

Prefer watching over reading? A video demonstration is available below:

Why inject?

Since Angular 14, the inject function has served as a substitute for constructor-based dependency injection. It offers the following benefits:

1. Consistency of Decorators

In the TypeScript environment, decorators have historically operated under experimental conditions.

The standardization of decorators is complete at TC39, but every decorator kind still needs finishing before the TypeScript compiler's experimental flag can be turned off.

tsconfig.json in Angular with experimental decorators

With dependency injection through the constructor, parameter decorators are required, which leaves room for issues down the line. In contrast, the inject function has no need for such decorators, reducing the likelihood of future disruptions.

class FlightSearchComponent {
  constructor(
    @SkipSelf() // not standardized
    @Optional() // not standardized
    private flightService1: FlightService
  ) {}

  private flightService2 = inject(FlightService, {
    skipSelf: true,
    optional: true,
  });
}
Enter fullscreen mode Exit fullscreen mode

2. Erased Type in the constructor

At runtime, the type of inject is still accessible. When a constructor is used, the compilation wipes out all type details other than the variable name. Consequently, the TypeScript compiler must inject specific metadata to preserve type information in the output. This behavior is not enabled by default in the compiler.

// TypeScript
class FlightService {}

class FlightSearchComponent {
  constructor(private flightService: FlightService) {}

  flightService2 = inject(FlightService);
}
Enter fullscreen mode Exit fullscreen mode
// Compiled JavaScript

class FlightService {
}

class FlightSearchComponent {
  // type is gone
  constructor(flightService) { 
    this.flightService = flightService;

    // type is still there
    this.flightService2 = inject(FlightService); 
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Class Inheritance

When working with class hierarchies, inject proves far more convenient. In a constructor-based setup, every subclass must supply the dependencies required by its superclass. Using inject, however, lets each parent class resolve its own dependencies independently.

// Inheritance and constructor-based dependency injection

class Animal {
  constructor(private animalService: AnimalService) {}
}

class Mammal extends Animal {
  constructor(
    private mammalService: MammalService,
    animalService: AnimalService
  ) {
    super(animalService);
  }
}

class Cat extends Mammal {
  constructor(
    private catService: CatService,
    mammalService: MammalService,
    animalService: AnimalService
  ) {
    super(mammalService, animalService);
  }
}
Enter fullscreen mode Exit fullscreen mode
// Inheritance via inject

class Animal {
  animalService = inject(AnimalService);
}

class Mammal extends Animal {
  mammalService = inject(MammalService);
}

class Cat extends Mammal {
  catService = inject(CatService);
}
Enter fullscreen mode Exit fullscreen mode

4. Type-Safe Injection Tokens

Injection tokens guarantee type safety.

const VERSION = new InjectionToken<number>('current version');

class AppComponent {
  //compiles, although VERSION is of type number
  constructor(@Inject('VERSION') unsafeVersion: string) {} 

  safeVersion: string = inject(VERSION); // fails 👍
}
Enter fullscreen mode Exit fullscreen mode

5. Functional Approaches

Certain functional patterns—NgRx Store being a prime example—lack a constructor, making inject their only viable option.


Given those numerous benefits, inject quickly gained prominence. For a while, it seemed the constructor-based pattern could fall out of favor entirely.

However, the landscape has evolved as of now. Alex Rickabaugh notes that property decorators have reached Stage 1 in the standardization process. He therefore advises choosing the approach that suits your situation and awaiting the outcome of the TC39 discussions.

Timestamp: 25:25

TestBed.inject

When application code adopts inject, testing often hits a snag, particularly around how objects get created. With a constructor-based approach, a test could spin up a class instance directly; inject removes that option, forcing tests to route through the TestBed instead.

For any Service or @Injectable under test, TestBed.inject is available from any point within the test file.

It works whether called right at the start, midway through, or after the test logic has run.

Here is the Service we aim to test:

@Injectable({ providedIn: "root" })
export class AddressAsyncValidator {
  #httpClient = inject(HttpClient);

  validate(ac: AbstractControl<string>): Observable<ValidationErrors | null> {
    return this.#httpClient
      .get<unknown[]>("https://nominatim.openstreetmap.org/search.php", {
        params: new HttpParams()
          .set("format", "jsonv2").set("q", ac.value),
      })
      .pipe(
        map((addresses) =>
          addresses.length > 0 ? null : { address: "invalid" }
        )
      );
  }
}
Enter fullscreen mode Exit fullscreen mode

Since AddressAsyncValidator relies on HttpClient through injection, that dependency needs to be mocked.

Our TestingModule doesn't require a component to be imported or instantiated.

The scenario is purely about testing logic—no UI or DOM rendering is involved.

describe("AddressAsyncValidator", () => {
  it("should validate an invalid address", waitForAsync(async () => {
    TestBed.configureTestingModule({
      providers: [
        {
          provide: HttpClient,
          useValue: { get: () => of([]).pipe(delay(0)) },
        },
      ],
    });

    const validator = TestBed.inject(AddressAsyncValidator);
    const isValid = await lastValueFrom(
      validator.validate({ value: "Domgasse 5" } as AbstractControl)
    );
    expect(isValid).toEqual({ address: "invalid" });
  }));
});
Enter fullscreen mode Exit fullscreen mode

The test passes, but two caveats deserve attention.

The first caveat: when AddressAsyncValidator lacks {providedIn: 'root'} and only @Injectable is present, adding the service to the TestingModule providers becomes mandatory:

@Injectable()
export class AddressAsyncValidator {
  // ...
}

describe("AddressAsyncValidator", () => {
  it("should validate an invalid address", waitForAsync(async () => {
    TestBed.configureTestingModule({
      providers: [
        AddressAsyncValidator,
        {
          provide: HttpClient,
          useValue: { get: () => of([]).pipe(delay(0)) },
        },
      ],
    });

    // rest of the test
  }));
});
Enter fullscreen mode Exit fullscreen mode

The second obstacle is that inject can't be invoked directly within the test. Attempting to do so triggers the standard error response.

NG0203: inject() must be called from an injection context such as a constructor, a factory function, a field initializer, or a function used with runInInjectionContext.

describe("AddressAsyncValidator", () => {
  it("should validate an invalid address", waitForAsync(async () => {
    TestBed.configureTestingModule({
      providers: [
        AddressAsyncValidator,
        {
          provide: HttpClient,
          useValue: { get: () => of([]).pipe(delay(0)) },
        },
      ],
    });

    const validator = inject(AddressAsyncValidator); // not good
  }));
});
Enter fullscreen mode Exit fullscreen mode

TestBed.runInInjectionContext

So why would a test ever need to call inject? The simple answer is: any time the code under test relies on it.

Within Angular itself, that can be something like an HttpInterceptorFn or a router guard such as CanActivateFn.

Across the Angular ecosystem right now, functional-style patterns are popping up all over the place.

A sensible starting point could be

GitHub logo nxtensions / nxtensions

Extensions and plugins for Nx

Nxtensions logo

Nxtensions

Run CI checks License: MIT Commitizen friendly

@nxtensions/astro @nxtensions/tsconfig-paths-snowpack-plugin

Nxtensions provides a collection of plugins and utilities designed for use with Nx.

Nx is a build framework known for its intelligence and extensibility. Its foundational capabilities include:

  • Generating and analyzing project and task graphs.
  • Running and coordinating tasks.
  • Caching computations to avoid redundant work.
  • Generating code efficiently.

The framework's base features can be expanded through plugins, enabling support for various frameworks and technologies that aren't covered by the standard Nx team plugins.

Package inventory

Get involved

Want to contribute? We encourage participation, so be sure to review our Contributors Guide.






GitHub logo feat(vite-plugin-angular): enable `.analog` support #870

nartc avatar
nartc shared this on

PR Checklist

Go through the list and confirm your PR satisfies the criteria:

  • [x] The commit message complies with the guidelines laid out in the contribution guide
  • [x] Tests were added for the change (for features or bug resolutions)
  • [ ] Docs were added or refreshed (for features or bug resolutions)

PR Type

What category of change is being introduced in this PR?

  • [ ] Bugfix
  • [x] Feature
  • [ ] Code style adjustment (formatting, scoped variables)
  • [ ] Refactor (no behavioral or API changes)
  • [ ] Build-related modifications
  • [ ] CI-related modifications
  • [ ] Documentation updates
  • [ ] Other... Please specify:

Which package are you modifying?

  • [x] vite-plugin-angular
  • [ ] vite-plugin-nitro
  • [ ] astro-angular
  • [ ] create-analog
  • [ ] router
  • [ ] platform
  • [ ] content
  • [ ] nx-plugin
  • [ ] trpc

What is the new behavior?

With this PR, the .analog file extension becomes supported by toggling the supportAnalogFormat flag located under vite.experimental.

This supersedes the previous .ng file extension. The .analog extension provides a more explicit (and deliberate) distinction. Feature parity with .ng is maintained, apart from these differences:

  • templateUrl now handles references to external HTML files
  • styleUrl and styleUrls now handle references to external stylesheet file(s)
  • <style> blocks (for inline styling) are mapped to the styles property in the Component metadata, rather than being injected into the <template>
    • Having several <style> blocks isn't assured. For multiple stylesheets, rely on styleUrls instead

Does this PR introduce a breaking change?

  • [ ] Yes
  • [x] No

Other information

[optional] What gif best expresses this PR's impact or your sentiment?

However, we'll stick to built-in capabilities and test a CanActivateFn:

export const apiCheckGuard: CanActivateFn = (route, state) => {
  const httpClient = inject(HttpClient);

  return httpClient.get("/holiday").pipe(map(() => true));
};
Enter fullscreen mode Exit fullscreen mode

The apiCheckGuard function serves a single purpose: it checks whether a request targeting the "/holiday" URL comes back successfully. Since this function lacks a constructor, the only way for it to gain access to dependencies is by calling inject.

Writing a test for this function might involve something like:

it("should return true", waitForAsync(async () => {
  TestBed.configureTestingModule({
    providers: [
      { provide: HttpClient, useValue: { get: () => of(true).pipe(delay(1)) } },
    ],
  });

  expect(await lastValueFrom(apiCheckGuard())).toBe(true);
}));
Enter fullscreen mode Exit fullscreen mode

The same approach fails here as well, once again triggering the NG0203 error.

Instead, we need to reach for TestBed.runInInjectionContext. Its name is quite descriptive: it takes any function and executes it within the injection context, so any inject calls inside that function can resolve successfully.

describe("Api Check Guard", () => {
  it("should return true", waitForAsync(async () => {
    TestBed.configureTestingModule({
      providers: [
        {
          provide: HttpClient,
          useValue: { get: () => of(true).pipe(delay(1)) },
        },
      ],
    });

    await TestBed.runInInjectionContext(async () => {
      const value$ = apiCheckGuard();
      expect(await lastValueFrom(value$)).toBe(true);
    });
  }));
});
Enter fullscreen mode Exit fullscreen mode

At first glance, you might think TestBed.runInInjectionContext offers the injection context in an asynchronous manner — but it doesn't.

The guard invokes inject in a synchronous fashion. Had it been called inside the pipe operator, the inject would execute within the async task and encounter the same failure.

Summary

Compared to constructor-based dependency injection, inject offers a range of benefits, so adopting it is worth consideration.

For function-based constructs like HttpInterceptorFn and router guards, inject serves as the exclusive means to access the dependency injection system.

To make inject work properly, those function calls need to be wrapped in TestBed.runInInjectionContext.

Additionally, there's TestBed.inject, which serves a distinct purpose — it is exclusive to tests and is meant for retrieving class instances, regardless of whether those classes rely on inject or constructor-based injection.


The repository is available at https://github.com/rainerhahnekamp/how-do-i-test

If there's a testing problem you'd like me to cover here, feel free to reach out!

For more updates, follow me on LinkedIn and X, and check out our website for workshops and consulting on testing services.

How do I test code using inject() — figure 11

ANGULARarchitects | Professional Angular Testing Workshop

favicon angulararchitects.io