DEV Community

Cover image for How do I test code using inject()
Rainer Hahnekamp for This is Angular

Posted on • Originally published at rainerhahnekamp.com

How do I test code using inject()

This article discusses testing Angular code, which uses the inject function for dependency injection.

If you are more of a visual learner, here's a video for you:

Why inject?

The inject function, introduced in Angular 14, is an alternative to the constructor-based dependency injection. inject has the following advantages:

1. Standardization of Decorators

Decorators have existed in an experimental setting within the TypeScript configuration.

While TC39 has completed the standardization of decorators, all decorator types must be finalized to disable the experimental mode of the TypeScript compiler.

tsconfig.json in Angular with experimental decorators

Unlike the constructor, the inject function works without parameter decorators, eliminating potential risks for future breaking changes associated with dependency injection via the constructor.



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

The type of inject is also available during runtime. With the constructor, only the variable name survives the compilation. So, the TypeScript compiler has to add specific metadata where type information is part of the bundle. This setting is not the default behavior of 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

inject is more accessible for class inheritance. With the constructor, a subclass has to provide all dependencies for its parents. With inject, the parent get their dependencies on their own.



// 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 are type-safe.



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

Some functional-based approaches (like the NgRx Store) that don't have a constructor can only work with inject.


Because of these many advantages, inject was the rising star. It almost looked like the constructor-based approach might become deprecated.

At the time of this writing, things shifted a little bit. According to Alex Rickabaugh, property decorators are in Stage 1 regarding standardization. That's why he recommends using whatever fits best and waiting for the results of the TC39.

Time Position: 25:25

TestBed.inject

Many tests face issues when the application code switches to inject. One of the main issues is the instantiation. With a constructor-based class, a test could directly instantiate the class, but with inject, that is impossible, and we always have to go via the TestBed.

Whenever dealing with a Service/@Injectable, we can call TestBed.inject from everywhere in our test.

That could be at the beginning of the test, in the middle, or even at the end.

We have the following Service, which we want 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

AddressAsyncValidator injects the HttpClient. So we have to mock that one.

There is no need to import or create a component in our TestingModule.

It is pure "logic testing". The test doesn't require a UI, i.e., DOM rendering.



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

That test will succeed. There are two remarks, though.

First, if AddressAsyncValidator has no {providedIn: 'root'} (only @Injectable is available), we have to provide the Service in the TestingModule:



@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

Second, we cannot run inject inside the test. That will fail with the familiar error message.

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

Why would we ever want to run inject in a test? Answer: Whenever we have to test a function which uses inject.

In the Angular framework, that could be an HttpInterceptorFn or one of the router guards, like CanActivateFn.

In the Angular community, we currently see many experiments with functional-based patterns.

A good start might 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 is a set of plugins and utilities for Nx.

Nx is a smart and extensible build framework. At its core, it offers many great features like:

  • Project and task graph creation and analysis.
  • Orchestration and execution of tasks.
  • Computation caching.
  • Code generation.

Its core functionality can be further extended with plugins to support frameworks and technologies not supported by the core plugins maintained by the Nx team.

List of packages

Contributing

Interested in contributing? We welcome contributions, please check out our Contributors Guide.






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

nartc avatar
nartc posted on

PR Checklist

Please check if your PR fulfills the following requirements:

PR Type

What kind of change does this PR introduce?

  • [ ] Bugfix
  • [x] Feature
  • [ ] Code style update (formatting, local variables)
  • [ ] Refactoring (no functional changes, no api changes)
  • [ ] Build related changes
  • [ ] CI related changes
  • [ ] Documentation content changes
  • [ ] Other... Please describe:

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?

This PR enables support for .analog file extension via the supportAnalogFormat flag under vite.experimental.

This replaces the .ng file extension support. With .analog, the separation is clearer (and intentional). Level of support is around the same as .ng with the following difference:

  • templateUrl is now supported for external HTML file
  • styleUrl and styleUrls are now supported for external style file(s)
  • <style> (for inline style) is now converted to styles property in the Component metadata instead of prepend in the <template> content
    • Multiple <style> is not guaranteed. If folks need multiple stylesheet, use styleUrls instead

Does this PR introduce a breaking change?

  • [ ] Yes
  • [x] No

Other information

[optional] What gif best describes this PR or how it makes you feel?

We will stick to native features, though, 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

apiCheckGuard is a simple function that verifies if a request to the URL "/holiday" succeeds. There is no constructor in a function. Therefore, apiCheckGuard must use inject.

A test could look like this:



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

That will also not work. We get the NG0203 error again.

The solution is TestBed.runInInjectionContext. As the name says, it allows us to run any function, which will run again in the injection context. That means the inject is active and will work as expected.



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

Although it looks like TestBed.runInInjectionContext provides the injection context asynchronously, that is not true.

The guard calls inject synchronously. If it did it in the pipe operator, inject would run in the asynchronous task and again fail.

Summary

inject comes with many advantages over the constructor-based dependency injection, and you should consider using it.

HttpInterceptorFn and router guards are functions and can only use inject to get access to the dependency injection.

To have a working inject, we must wrap those function calls within TestBed.runInInjectionContext.

There is also TestBed.inject, which behaves differently. It is only available in tests and we should use it get instances of classes. Regardless, if those classes use inject or the constructor-based dependency injection.


You can access the repository at https://github.com/rainerhahnekamp/how-do-i-test

If you encounter a testing challenge you'd like me to address here, please get in touch with me!

For additional updates, connect with me on LinkedIn, X, and explore our website for workshops and consulting services on testing.

Top comments (5)

Collapse
 
stealthmusic profile image
Jan Wedel

Generally I would always prefer constructor injection. It allows writing framework agnostic unit tests.

We have rewritten all of our tests using Jest and we are never using TestBed because it was extremely slow in comparison.

Does it mean, that the new TestBed by default does not run on a DOM?

Still, it is quite disappointing, that we now would need a lot of framework code to write unit tests.

Collapse
 
rainerhahnekamp profile image
Rainer Hahnekamp

Hi Jan,

We have an official statement from the Angular team. In short: TestBed all-in github.com/angular/angular/issues/...

Alex is also addressing your issue regarding performance.

Collapse
 
stealthmusic profile image
Jan Wedel • Edited

Thanks, very interesting to read. I can somewhat relate to the points made.

I think it’s wrong on an architechtural level. I had the same feeling when I saw inject() the first time.

Like making all those changes and then requiring the unit tests to follow seems like a natural choice but maybe the initial assumption is wrong.

We will see. So it’s right, we used TestBed prior to Ivy. So speed might be ok now.

We have about 600 unit tests and I never had a problem with mocked framework code. We usually move most of the logic into services that can be easily unit tested. Their points about ViewChild etc are only valid for components.

So when they deprecate constructor injection also for services, it will
Be hell of a work to change all the tests.

Thread Thread
 
rainerhahnekamp profile image
Rainer Hahnekamp

So do you have a frontend containing an unusual high amount of logic?

From my experience, frontends are very simple, but there are always some exceptions where the possibility of writing unit tests is quite important. If you don't want to go with that abstraction layer mentioned, the constructor might be the better option.

In a way, this testing discussion unit tests vs. component testing is very similar to RxJs vs. Signals. Most applications would benefit from the simplicity of Signals, but some require more, and therefore, RxJs is the one that saves the day for them.

Thread Thread
 
stealthmusic profile image
Jan Wedel

Yes, we have a highly interactive data analysis tool with charts, multiple ngrx stores etc. We have put already all the logic possible in our BFF.

Yeah, we will see. I also like the simplicity of signals very much but I also believe that we have to stick with RxJs for the most parts.

I just fear the need to rewrite all of our test, potentially without any benefits as we will not be able to use signals a lot and currently not advantage of using inject().