Skip to content

Commit

Permalink
feat: provide a way to run tests in automatic mode
Browse files Browse the repository at this point in the history
BREAKING CHANGE:
ngx-speculoos now allows running test in _automatic_ mode. in this mode,
instead of imperatively triggering a change detection, you await the stability
and thus let Angular decide if a CD is necessary or not.
See the "Upgrading to v13" section of the README for details.
  • Loading branch information
jnizet committed Dec 6, 2024
1 parent 313c025 commit 758ff9c
Show file tree
Hide file tree
Showing 16 changed files with 690 additions and 106 deletions.
161 changes: 151 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ how to write Angular unit tests.
- [Getting started](#getting-started)
- [Features in details](#features-in-details)
- [ComponentTester](#componenttester)
- [Automatic change detection](#automatic-change-detection)
- [Queries](#queries)
- [Queries for elements](#queries-for-elements)
- [CSS and Type selectors](#css-and-type-selectors)
Expand All @@ -37,6 +38,7 @@ how to write Angular unit tests.
- [Can I use the TestElement methods to act on the component element itself, rather than a sub-element?](#can-i-use-the-testelement-methods-to-act-on-the-component-element-itself-rather-than-a-sub-element)
- [Issues, questions](#issues-questions)
- [Complete example](#complete-example)
- [Upgrading to v13](#upgrading-to-v13)

## Quick presentation

Expand Down Expand Up @@ -181,14 +183,82 @@ describe('My component', () => {
});

tester = new MyComponentTester();
tester.detectChanges();
tester.change();
});

it('should ...', () => {

});
```
### Automatic change detection
The future of Angular is zoneless. Without ZoneJS, components have to make sure to properly notify
Angular that they must be checked for changes, typically by updating signals.
Instead of imperatively triggering change detections in tests, it's thus a better idea to let
Angular decide if change detection must be run, in order to spot bugs where the component doesn't
properly handle its state changes.
This can be done by:
- adding a provider in the testing module to configure the fixtures to be in _automatic_ mode
- awaiting the component fixture stability when the test *thinks* that a change detection should
automatically happen.
When the `provideAutomaticChangeDetection()` provider is added, the `ComponentTester` will run in
_automatic_ mode. In this mode, calling `detectChanges()` throws an error, because you should always
let Angular decide if change detection is necessary.
Here's an example of a test that uses this technique:
```ts
class AppComponentTester extends ComponentTester<AppComponent> {
constructor() {
super(AppComponent);
}

get incrementButton() {
return this.button('button');
}

get count() {
return this.element('#count');
}
}

describe('AppComponent', () => {
let tester: AppComponentTester;

beforeEach(async () => {
TestBed.configureTestingModule({
providers: [
provideComponentFixtureAutoDetection(),
provideExperimentalZonelessChangeDetection() // if you already uses zoneless also add this provider
]
});

jasmine.addMatchers(speculoosMatchers);

tester = new AppComponentTester();
// a first call to change() is necessary to let Angular run its first change detection
await tester.change();
});

it('should display the counter value and increment it', async () => {
expect(tester.count).toHaveText('0');

// this clicks the button and then lets Angular decide if a CD is necessary, and waits until
// the DOM has been updated (or not)
await tester.incrementButton.click();

expect(tester.count).toHaveText('1');
});
});
```
In _automatic_ mode, your test functions should be `async`, and each action you do with the elements
(`click()`, `dispatchEvent`, etc.) should be awaited.
### Queries
#### Queries for elements
Expand Down Expand Up @@ -281,12 +351,12 @@ class TestDatepicker extends TestHtmlElement<HTMLElement> {
return this.input('input');
}

setDate(year: number, month: number, day: number) {
this.inputField.fillWith(`${year}-${month}-${day}`);
async setDate(year: number, month: number, day: number) {
await this.inputField.fillWith(`${year}-${month}-${day}`);
}

toggleDropdown() {
this.button('button').click();
async toggleDropdown() {
await this.button('button').click();
}
}
```
Expand All @@ -301,14 +371,26 @@ get birthDate() {
```

```typescript
it('should not save if birth date is in the future') {
it('should not save if birth date is in the future', () =>) {
// ...
tester.birthDate.setDate(2200, 1, 1);
tester.save.click();
expect(userService.create).not.toHaveBenCalled();
}
});
```

or, in _automatic_ mode

```typescript
it('should not save if birth date is in the future'), async () => {
// ...
await tester.birthDate.setDate(2200, 1, 1);
await tester.save.click();
expect(userService.create).not.toHaveBenCalled();
});
```


#### Subqueries

A query is made from the root `ComponentTester`. But `TestElement` themselves also support queries.
Expand Down Expand Up @@ -540,10 +622,10 @@ using the usual queries.
## Gotchas
### When do I need to call `detectChanges()`?
### When do I need to call `change` or `detectChanges()`?
Any event dispatched through a `TestElement` automatically calls `detectChanges()` for you.
But you still need to call `detectChanges()` by yourself in the other cases:
In _imperative_ mode, any event dispatched through a `TestElement` automatically calls `detectChanges()` for you.
But you still need to call `change()` or `detectChanges()` by yourself in the other cases:
- to actually initialize your component. Sometimes, you want to configure some mocks before the `ngOnInit()`
method of your component is called. That's why creating a `ComponentTester` does not automatically call
Expand All @@ -553,6 +635,15 @@ But you still need to call `detectChanges()` by yourself in the other cases:
by changing the state, or emitting an event through a subject, or triggering a navigation
from the `ActivatedRouteStub`
Note that, in _imperative_ mode, `change()` calls `detectChanges()`. So you can call either one of the other
when you want to trigger a change detection.
In _automatic_ mode, any event dispatched through a `TestElement` automatically calls `await change()` for you.
But you still need to call `await change()` by yourself in the same other cases as in the _imperative_ mode:
- to actually initialize your component.
- to force change detection once you've changed the state of your component without dispatching an event.
### Can I use the `TestElement` methods to act on the component element itself, rather than a sub-element?
Yes. The `ComponentTester` has a `testElement` property, which is the `TestHtmlElement` wrapping the component's element.
Expand All @@ -564,3 +655,53 @@ Please, provide feedback by filing issues, or by submitting pull requests, to th
## Complete example
You can look at a minimal complete example in the [demo](https://github.com/Ninja-Squad/ngx-speculoos/tree/master/projects/demo/src/app) project.
## Upgrading to v13
Version 13 of `ngx-speculoos` introduces the _automatic_ mode, consisting in using automatic change detection
instead of imperatively running change detections. See the [Automatic change detection](#automatic-change-detection)
section above for details.
As a result, all the methods that used to call `detectChanges()` for you now return a `Promise` instead of returning
`void`. In _imperative_ mode (the default), they are in fact synchronous and call `detectChanges()`, just as before.
In _automatic_ mode however, they call `await change()` and should thus be awaited.
Your tests should generally keep compiling and running without changes.
But if you created custom test elements which override methods that now return a promise, and return something
other than `void`, for example:
```typescript
class CustomInput extends TestInput {
//...
fillWith(s: string): CustomInput {
super.fillWith(s);
return this;
}
}
```
Then that won't compile anymore.
And in general, if you want your custom test element to be usable in both modes, all their method that explicitly
or indirectly called `detectChanges()` should now return a promise and explicitly of indirectly call `await change()`.
For example:
```typescript
class CustomInput extends TestHtmlElement {
//...
async fillInput(s: string): Promise<void> {
await this.element('input').fillWith(s);
// ...
}

async clickButton(): Promise<void> {
await this.element('button').click();
// ...
}

async changeState(): Promise<void> {
this.component(Foo).doSomething();
await this.change();
}
}
```
44 changes: 39 additions & 5 deletions projects/demo/src/app/app.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
import { ComponentTester, speculoosMatchers } from 'ngx-speculoos';
import { ComponentTester, provideAutomaticChangeDetection, speculoosMatchers } from 'ngx-speculoos';

class AppComponentTester extends ComponentTester<AppComponent> {
constructor() {
Expand Down Expand Up @@ -30,10 +30,8 @@ describe('AppComponent', () => {
beforeEach(() => {
tester = new AppComponentTester();

// a first call to detectChanges() is necessary. If the component had inputs, you would initialize them
// before calling detectChanges. For example:
// tester.componentInstance.someInput = 'someValue';
tester.detectChanges();
// a first call to change() is necessary.
tester.change();

jasmine.addMatchers(speculoosMatchers);
});
Expand All @@ -55,3 +53,39 @@ describe('AppComponent', () => {
expect(tester.greeting).toContainText('Hello John');
});
});

describe('AppComponent in automatic mode', () => {
let tester: AppComponentTester;

beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideAutomaticChangeDetection()]
});
});

beforeEach(async () => {
tester = new AppComponentTester();

// a first call to change() is necessary.
await tester.change();

jasmine.addMatchers(speculoosMatchers);
});

it('should display an empty form, with a disabled submit button and no greeting', () => {
expect(tester.firstName).toHaveValue('');
expect(tester.submit.disabled).toBe(true);
expect(tester.greeting).toBeNull();
});

it('should enable the submit button when filling the first name', async () => {
await tester.firstName.fillWith('John');
expect(tester.submit.disabled).toBe(false);
});

it('should display the greeting when submitting the form', async () => {
await tester.firstName.fillWith('John');
await tester.submit.click();
expect(tester.greeting).toContainText('Hello John');
});
});
34 changes: 30 additions & 4 deletions projects/ngx-speculoos/src/lib/component-tester.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, ComponentFixtureAutoDetect, TestBed } from '@angular/core/testing';
import { DebugElement, ProviderToken, Type } from '@angular/core';
import { TestTextArea } from './test-textarea';
import { TestElement } from './test-element';
Expand All @@ -26,6 +26,11 @@ export class ComponentTester<C> {
*/
readonly fixture: ComponentFixture<C>;

/**
* The mode used by the ComponentTester
*/
readonly mode: 'imperative' | 'automatic';

/**
* Creates a component fixture of the given type with the TestBed and wraps it into a ComponentTester
*/
Expand All @@ -47,7 +52,9 @@ export class ComponentTester<C> {
*/
constructor(arg: Type<C> | ComponentFixture<C>) {
this.fixture = arg instanceof ComponentFixture ? arg : TestBed.createComponent(arg);
const autoDetect = TestBed.inject(ComponentFixtureAutoDetect, false);
this.testElement = TestElementQuerier.wrap(this.debugElement, this) as TestElement<HTMLElement>;
this.mode = autoDetect ? 'automatic' : 'imperative';
}

/**
Expand Down Expand Up @@ -389,17 +396,36 @@ export class ComponentTester<C> {
}

/**
* Triggers a change detection using the wrapped fixture
* Triggers a change detection using the wrapped fixture in imperative mode.
* Throws an error in autodetection mode.
* You should generally prever
*/
detectChanges(checkNoChanges?: boolean): void {
if (this.mode === 'automatic') {
throw new Error('In automatic mode, you should not call detectChanges');
}
this.fixture.detectChanges(checkNoChanges);
}

/**
* Delegates to the wrapped fixture whenStable and then detect changes
* In imperative mode, runs change detection.
* In implicit mode, awaits stability.
*/
async change() {
if (this.mode === 'automatic') {
await this.stable();
} else {
this.fixture.detectChanges();
}
}

/**
* Delegates to the wrapped fixture whenStable and, in imperative mode, detect changes
*/
async stable(): Promise<void> {
await this.fixture.whenStable();
this.detectChanges();
if (this.mode === 'imperative') {
this.detectChanges();
}
}
}
19 changes: 19 additions & 0 deletions projects/ngx-speculoos/src/lib/providers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';
import { ComponentFixtureAutoDetect } from '@angular/core/testing';

const COMPONENT_FIXTURE_AUTO_DETECTION: EnvironmentProviders = makeEnvironmentProviders([
{ provide: ComponentFixtureAutoDetect, useValue: true }
]);

/**
* Provide function which returns the provider `{ provide: ComponentFixtureAutoDetect, useValue: true }`.
* This provider can be added to the testing module to configure the component testers
* (and the underlying ComponentFixture) in automatic mode:
*
* ```
* TestBed.configureTestingModule({ providers: [provideAutomaticChangeDetection()] });
* ```
*/
export function provideAutomaticChangeDetection(): EnvironmentProviders {
return COMPONENT_FIXTURE_AUTO_DETECTION;
}
Loading

0 comments on commit 758ff9c

Please sign in to comment.