Test Angular Reactive Forms using RxJS Marbles
Asked Answered
L

2

9

Angular Component

public setupObservables() {
  this.formFieldChanged$ = this.formField
    .valueChanges
    .pipe(
        debounceTime(100),
        distinctUntilChanged((a, b) => a === b),
    )
}

Jasmine Test

import { of } from 'rxjs';
import { marbles } from 'rxjs-marbles/jasmine';  
...

it('should update value on debounced formField change', marbles(m => {
  const values = { a: "1", b: "2", c: "3" };

  const fakeInputs = m.cold('a 200ms b 50ms c', values);
  const expected = m.cold('100ms a 250ms c', values);

  // works on stackblitz but otherwise gives TS2540 compiler error
  // cannot assign to a read-only property
  component.formField.valueChanges = fakeInputs; 
  component.setupObservables();

  m.expect(component.formFieldChanged$).toBeObservable(expected);
}));

stackblitz.com example

The intent is to use marble tests to test Observable code in context with Angular reactive forms.

  • Does this approach make sense?
  • How to best mock the valueChanges of a FormField object?
  • Is there a better way to structure these kind of tests?
Lim answered 17/4, 2020 at 14:35 Comment(6)
Can you make a demo on stackblitz? In general, valueChanges is a Subject that emits values. By comparing it to fakeInputs you're not comparing individual emissions. What you need instead is expectObservable github.com/ReactiveX/rxjs/blob/master/docs_app/content/guide/…Disgrace
stackblitz demo addedLim
component.formField.valueChanges = fakeInputs instead of the spyOn works.Lim
yeah, so you've basically solved it yourself. You can make it even more simple by using just const expected = '100ms a 500ms c'; and then pass values to toBeObservable.Disgrace
Works on stackblitz but TS won't compile it due to valueChanges being a read-only property.Lim
You can typecast it and then override (component.formField as any).valueChanges = fakeInputs.Disgrace
B
6

the question is - what you want to test. is it a unit test or an e2e test? if it's a unit test - mock reactive forms, cover only your logic, then you don't have an issue with valueChanges, because it's mocked and you control it.

if it's an e2e test - you shouldn't reassign valueChanges. Nothing should be mocked / replaced, because it's an e2e test.

Nevertheless if you want to change valueChanges - use https://github.com/krzkaczor/ts-essentials#writable

(Writable<typeof component.formField>component.formField).valueChanges = fakeInputs; 

It will make the property type writable.

If it's a unit test, personally, I would vote to mock the reactive form, because in a unit test we need to test only our unit, its dependencies should be mocked / stubbed.

Injection of parts we want to mock

As an option you can move the form as a dependency of your component to providers in the component declarations.

@Component({
    selector: 'app-component',
    templateUrl: './app-component.html',
    styleUrls: ['./app-component.scss'],
    providers: [
        {
            provide: 'form',
            useFactory: () => new FormControl(),
        },
    ],
})
export class AppComponent {
    public formFieldChanged$: Observable<unknown>;

    constructor(@Inject('form') public readonly formField: FormControl) {
    }

    public setupObservables(): void {
        this.formFieldChanged$ = this.formField
            .valueChanges
            .pipe(
                debounceTime(100),
                distinctUntilChanged((a, b) => a === b),
            );
    }
}

Then you can simply inject a mock instead of it in a test.

it('should update value on debounced formField change', marbles(m => {
    const values = { a: "1", b: "2", c: "3" };

    const fakeInputs = m.cold('a 200ms b 50ms c', values);
    const expected = m.cold('100ms a 250ms c', values);

    const formInput = {
        valueChanges: fakeInputs,
    };

    const component = new AppComponent(formInput as any as FormControl);

    component.setupObservables();
    m.expect(component.formFieldChanged$).toBeObservable(expected);
}));
Bodily answered 25/4, 2020 at 16:29 Comment(6)
My question was mainly about unit testing. To mock reactive forms I have to use Writable or is there a more straightforward way to do this?Lim
yes, for unit testing you can simply mark it writable. If you want, the very right way would be to inject this.formField as a dependency to the component, then you could mock it without Writable. If you want I can update my answer to show an example.Bodily
Please do, that would be great for others looking for a complete solution.Lim
updated, unfortunately, I don't know name of your component and used AppComponent instead.Bodily
It is not possible to use lambdas in decorators - new FormControl() would need to be in an exported function but otherwise looks like solid approach.Lim
I think it's unsupported in very very old TS, we use it in 3.7 and 3.8 without any issues.Bodily
L
0

Using properties instead of fields is a much cleaner solution.

get formFieldChanged$() { return this._formFieldChanged$; }
private _formFieldChanged$: Observable<string>;

...
   
public setupObservables() {
  this._formFieldChanged$ = this.formField
    .valueChanges
    .pipe(
        debounceTime(100),
        distinctUntilChanged((a, b) => a === b),
    )
}

spyOnProperty does the magic here, setupObservables() is no longer needed:

it('should update value on debounced formField change', marbles(m => {
  const values = { a: "1", b: "2", c: "3" };

  const fakeInputs = m.cold('a 200ms b 50ms c', values);
  const expected = m.cold('100ms a 250ms c', values);

  spyOnProperty(component, 'formFieldChanged$').and.returnValue(fakeInputs);

  m.expect(component.formFieldChanged$).toBeObservable(expected);
}));

Lim answered 24/6, 2021 at 9:6 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.