1

I've successfully migrated my Angular 18 project to Angular 19 and replaced all @Input decorators with the new input() signals. The code works but the test for a directive with input() signals breaks.

Here is my directive, it just multiplies values of a form group.

import { Directive, OnInit, input } from "@angular/core";
import { FormGroup, FormGroupDirective } from "@angular/forms";
import { FormItem } from "../model/form.model";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";

@UntilDestroy()
@Directive({
  selector: "[appMultiply]",
  standalone: true,
})
export class MultiplyDirective implements OnInit {
  constructor(formGroupDirective: FormGroupDirective) {
    this.form = formGroupDirective.form;
  }

  readonly appMultiply = input<FormItem>();
  readonly formDefinition = input<Array<FormItem>>();

  private form: FormGroup;

  ngOnInit(): void {
    const appMultiply = this.appMultiply();
    if (!appMultiply?.multiply || !this.formDefinition()) {
      return;
    }

    const sourceControl = this.form.get(appMultiply.formControlName);

    if (!sourceControl) {
      return;
    }

    //initial calculation
    this.calculateTargetControlValues(appMultiply, sourceControl.value);

    //wait for source changes
    sourceControl.valueChanges
      .pipe(untilDestroyed(this))
      .subscribe((value) =>
        this.calculateTargetControlValues(this.appMultiply()!, value),
      );
  }

  private calculateTargetControlValues = (
    appMultiply: FormItem,
    sourceValue: number,
  ) => {
    const multiplyByControl = this.form.get(
      appMultiply.multiply!.multiplyByControlName,
    );

    if (!multiplyByControl) {
      return;
    }

    this.formDefinition()!
      .filter((formItem) =>
        this.appMultiply()!.multiply!.targetFormControlNames.includes(
          formItem.formControlName,
        ),
      )
      .forEach((formItem) => {
        const targetControl = this.form.get(formItem.formControlName);
        if (!targetControl) {
          return;
        }
        const result = (sourceValue ?? 0) * (multiplyByControl.value ?? 0);
        targetControl.patchValue(result, { emitEvent: false });
      });
  };
}

This is the basic test of the directive:

import { FormGroupDirective } from "@angular/forms";
import { MultiplyDirective } from "./multiply.directive";

describe("MultiplyDirective", () => {
  it("should create an instance", () => {
    const directive = new MultiplyDirective(new FormGroupDirective([], []));
    expect(directive).toBeTruthy();
  });
});

And I get this error from Karma : "Error: NG0203: inputFunction() can only be used within an injection context such as a constructor, a factory function, a field initializer, or a function used with runInInjectionContext. Find more at https://angular.dev/errors/NG0203"

If I replace the 2 input signals with @Input decorator inputs, the test runs successfully. Like this :

  @Input() appMultiply!: FormItem
  @Input() formDefinition!: Array<FormItem>

What do i do wrong?

2 Answers 2

0

You can use the runInInjectionContext context method to make the directive initialize. We have to provide the first argument as the EnvironmentInjector to the method.

beforeEach(waitForAsync(() => {
  fixture = TestBed.createComponent(AppComponent);
  injector = TestBed.inject(EnvironmentInjector);
  // injector = TestBed.inject(Injector);
  component = fixture.componentInstance;
  fixture.detectChanges();
}));

it('should create', () => {
  const directive = runInInjectionContext(injector, () => {
    return new MultiplyDirective(new FormGroupDirective([], []));
  });
  expect(directive).toBeTruthy();
});

Full Code:

import { TestBed, ComponentFixture, waitForAsync } from '@angular/core/testing';
import { AppComponent, MultiplyDirective } from './app.component';
import { AppModule } from './app.module';
import { FormGroupDirective } from '@angular/forms';
import {
  runInInjectionContext,
  Injector,
  EnvironmentInjector,
} from '@angular/core';

describe('AppComponent', () => {
  let component: AppComponent;
  let fixture: ComponentFixture<AppComponent>;
  let injector: EnvironmentInjector;
  // let injector: Injector;
  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [AppModule],
    }).compileComponents();
  });

  beforeEach(waitForAsync(() => {
    fixture = TestBed.createComponent(AppComponent);
    injector = TestBed.inject(EnvironmentInjector);
    // injector = TestBed.inject(Injector);
    component = fixture.componentInstance;
    fixture.detectChanges();
  }));

  it('should create', () => {
    const directive = runInInjectionContext(injector, () => {
      return new MultiplyDirective(new FormGroupDirective([], []));
    });
    expect(directive).toBeTruthy();
  });
});

Stackblitz Demo

Sign up to request clarification or add additional context in comments.

2 Comments

I think this answer should come with a warning, that creating a directive outside of DI is not recommended. Anything created by the framework should be tested via framework tools (TestBed).
@MatthieuRiegler no warning, check my stackblitz, the code you provide takes lot of effort to setup (good for view tests), especially when you just want to unit test the functions and not involve the view(my answer focuses on unit testing the functions)
0

The new signal input() requires you to create your components with a Dependency injection context.

To test your directive with TestBed you need to do it via a host component.

it('', () => {
    @Component({
      imports: [ReactiveFormsModule, MyDirective],
      template: `<form [formGroup]="fg" myDir></form>`
    })
    class TestComponent {
      fg= new FormGroup({foo: new FormControl('bar')})
    }



    const fixture = TestBed.createComponent(TestComponent);
    const directive = fixture.debugElement.query(By.directive(MyDirective));

    console.log(directive);
    expect(directive).toBeDefined();
    
expect(directive.componentInstance.formGroupDirective).toBeDefined()
});

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.