6

I wanted to use template forms and [min] and [max] directives, so I have created them and they work. But the test confuses me: validation is not executed asynchronously, yet after changing my values and stuff, I have to go through this:

component.makeSomeChangeThatInvalidatesMyInput();
// control.invalid = false, expected

fixture.detectChanges();
// control.invalid is still false, not expected

// but if I go do this
fixture.whenStable().then(() => {
  // control.invalid is STILL false, not expected
  fixture.detectChanges();
  // control.invalid now true
  // expect(... .errors ... ) now passes
})

I don't understand why would I need even that whenStable(), let alone another detectChanges() cycle. What am I missing here? Why do I need 2 cycles of change detection for this validation to be executed?

Doesn't matter if I run the test as async or not.

Here's my test:

@Component({
    selector: 'test-cmp',
    template: `<form>
        <input [max]="maxValue" [(ngModel)]="numValue" name="numValue" #val="ngModel">
        <span class="error" *ngIf="val.invalid">Errors there.</span>
    </form>`
})
class TestMaxDirectiveComponent {
    maxValue: number;
    numValue: number;
}
fdescribe('ValidateMaxDirective', () => {
    let fixture: ComponentFixture<TestMaxDirectiveComponent>;
    let component: TestMaxDirectiveComponent;

    beforeEach(async(() => TestBed.configureTestingModule({
        imports: [FormsModule],
        declarations: [TestMaxDirectiveComponent, ValidateMaxDirective],
    }).compileComponents()
        .then(() => {
            fixture = TestBed.createComponent(TestMaxDirectiveComponent);
            component = fixture.componentInstance;
            return fixture.detectChanges();
        })
    ));
    fit('should have errors even when value is greater than maxValue', async(() => {
        component.numValue = 42;
        component.maxValue = 2;
        fixture.detectChanges();
        fixture.whenStable().then(() => {
            fixture.detectChanges();
            expect(fixture.nativeElement.querySelector('.error')).toBeTruthy();
        });
    }));
});

And here's the directive itself (simplified a bit):

const VALIDATE_MAX_PROVIDER = {
    provide: NG_VALIDATORS, useExisting: forwardRef(() => ValidateMaxDirective), multi: true,
};
@Directive({
    selector: '[max][ngModel]',
    providers: [VALIDATE_MAX_PROVIDER],
})
export class ValidateMaxDirective implements Validator {
    private _max: number | string;
    @Input() get max(): number | string {
        return this._max;
    }
    set max(value: number | string) {
        this._max = value;
    }

    validate(control: AbstractControl): ValidationErrors | null {
        if (isEmptyInputValue(control.value) || isEmptyInputValue(this._max)) {
            return null;  // don't validate empty values to allow optional controls
        }
        const value = parseFloat(control.value);
        return !isNaN(value) && value > this._max ? {'max': {'max': this._max, 'actual': control.value}} : null;
    }
}

I have tested this on a brand new ng new app with @angular/cli version 1.6.8 and latest angular 5.2.

Zlatko
  • 18,936
  • 14
  • 70
  • 123
  • I was using ``whenStable`` before as you do, but tests are very complex to get them right for non simple test case like testing many condition one after another. I prefer to create non async tests using fakeAsync and/or tick. this way you have more control on your tests. you can try to create a demo like I did here in one of my answers that you can let people to understand and help you more: https://stackblitz.com/edit/angular-testing-c25ezq?file=app/app.component.spec.ts – HDJEMAI Mar 31 '18 at 00:34
  • Yep, thanks for the opinion. I do know how to make it work, I'm just asking the question to find out why it doesn't work the way I thought it should. – Zlatko Apr 01 '18 at 10:13
  • Hey! interesting question! I know this might be mildly annoying to hear, but in this case there is legitimately no way around it. could you post your **entire** files **untampered** and **including imports** please? I have an idea why but I need to validate it first. – tatsu Apr 25 '18 at 09:09
  • @tatsu cool, spec posted here: https://pastebin.com/gD9Q7CPA and directive here: https://pastebin.com/eUWEkSpV The same goes for other similar directives. – Zlatko Apr 25 '18 at 09:17
  • ok. maybe I'm missing something, but how are you calling `.detectChanges();` without importing `ChangeDetectorRef` ? – tatsu Apr 25 '18 at 09:30
  • It's a ComponentFixture, it has it's own. – Zlatko Apr 25 '18 at 09:31
  • the issue I see with that, and again please do correct me if I'm wrong, is that you can't configure the `ChangeDetectionStrategy` on `ComponentFixture` the way you can on a `ChangeDetectorRef` or if you can, that is what you should do : https://angular.io/api/core/ChangeDetectionStrategy#OnPush if I follow correctly, you want `ChangeDetectionStrategy.Default` – tatsu Apr 25 '18 at 09:56
  • Not exactly. See, why does the change detection work, but only after I call detectChanges(), then whenStable() then detectChanges() again? That sounds completely faulty to me. If I only call the first detectChanges, the change is not detected. So, the strategy is not at fault here. The test framework seems to be. – Zlatko Apr 25 '18 at 10:23
  • @Zlatko so if I'm reading this well : https://codecraft.tv/courses/angular/unit-testing/asynchronous/#_code_async_code_and_code_whenstable_code "Only when all of those pending promises have been resolved does it then resolves the promise returned from whenStable." and https://angular.io/guide/testing#async-test-with-async then this behavior is expected when dealing with asynchronous functions. you'll only be able to use `detectChanges()` inside a `.whenStable()`'s callback and successfully get the current state. come to think of it this is how all subscribables (including observables) work. – tatsu Apr 25 '18 at 16:13
  • So what is async in the code above? – Zlatko Apr 26 '18 at 06:27

1 Answers1

5

After our conversation I've got it. You asked me what is async in the code above :

validate() is !

we see that this method takes control: AbstractControl as a parameter

in it's docs you'll find that as well as a synchronous behavior this handles asynchronous validation.

So I'm running on the assumption here that the adding of that parameter turned validate() asynchronous.

this in turn means that you need to wait for it's eventual return to assess whether there have been changes or not.

...This being the only function likely to trigger a change, we depend on it when we .detectChanges();.

and in any async case in javascript values (variable) are to be imagined using the time dimension on top of whatever others they may already possess.

as such developers in the javascript community have adopted the "marbles on a string" or "birds on a telephone line" metaphors to help explain them.

the common theme being a lifeline/timeline. Here's another, my own personal representation :

enter image description here

you'll have to .subscribe() or .then() to have what you want executed executed at the time of hydration/return.

so when you :

component.makeSomeChangeThatInvalidatesMyInput(); // (1)
fixture.detectChanges();                          // (2)
fixture.whenStable()                              // (3)
.then(() => {                                     // (not a step) :we are now outside the
                        //logic of "order of execution" this code could happen much after.
  fixture.detectChanges();
})
  • In step (2) you are effectively making that first assessment in my diagram above, the one straight onto the timeline where nothing has yet happened.

  • but in (not a step) you are listening for everytime there is a change (so potentially many calls). you get the expected value there at last because the code execution for assessing is happening "right on time" to catch the correct result; even better, it's happening because of the result(s).

  • and yes only detectChanges() can detect the changes so assessing before you run detectChanges(), even once within the .then(), will return a premature value.

the result that your first .detectChanges() does not detect a change whereas your fixture.whenStable().then(() => {fixture.detectChanges()}) does is not a bug and is javascript functioning as intended.

(that includes jasmine, jasmine is pure javascript)

So there you have it! there was no odd behavior after all :)

hope this helps!

tatsu
  • 2,316
  • 7
  • 43
  • 87
  • 1
    But i am using sync vakidator. Check the tokens NG_VALIDATORS and NG_VALIDATORS_ASYNC. You have to explicitly make a validator async for angular to treat ut so. Neverthrless, I will award you the bounty as the best answer , but I won't accept it as my question remains unanswered. Thanks for all the effort and patience. – Zlatko May 01 '18 at 18:03
  • 1
    Furthermore, the validate method is sync. It returns right away. – Zlatko May 01 '18 at 18:05
  • @Okay. sorry for being off the mark. I think you may want to be wary that a method returning right away is not a sufficient indicator of whether it is sync or not. I do believe you though. – tatsu May 02 '18 at 12:40
  • I know. It's just that, if the interface is explicit ( `NG_VALIDATORS` vs `NG_VALIDATORS_ASYNC`), I don't know what I can do more. Even if I replace the code above with return true, it gives the same result. How more sync can you get? – Zlatko May 02 '18 at 14:40