extending.md
March 4, 2024 Β· View on GitHub
π Β β Β Documentation Β β Β Custom Extension Functions
π‘ New to ngtx?
If you're new to ngtx and don't know the basic concepts yet, better start here first!
Extending ngtx' Declarative Api
ngtx comes with several neat predicates and assertions but there will probably be (quite some) cases where you need to add custom logic in order to make your test suite fit your needs. ngtx' api is designed with extendability in mind. Let's start.
Custom Predicates: CartView Example
Let's say we have a cart-view where a user gets listed what they are about to purchase. In Angular we would use a CartService to provide the cart data.
When testing, we don't want to use the "real" CartService as it might make calls to the network, which causes side-effects and also slows our tests down. Instead we would use a CartTestingService that looks all the same in regard to its API, but in fact does nothing behind the scenes.
In order to test, that our cart view correctly interacts with the fake version of our CartService, we need a predicate, allowing us to update that service's state. With this predicate, we are able to control, what items will be handed from the service to the view.
The Result
ngtx actually does provide a solution to that problem via the provider(<token>)-predicate, but for this example we just write a similar predicate ourselves. Let's start with a draft, how we would like to use our extension later in our tests:
it('should render all cart items', () => {
When(host)
.has(
// the predicate-extension we are about to build:
providerWithState(CartService, {
cartItems: [
{ name: 'pizza', price: 5.25 },
{ name: 'ice cream', price: 2.5 },
],
}),
)
.expect(the.CartItems)
.to(beFound({ times: 2 }));
});
Ok, looks decent enough to go to the next step: the implementation. The first thing we have to do is to define the function and its parameter list:
const providerWithState = (token: any, stateDef: any) => {
// yet to come
};
// or - with better type support:
import { Type } from '@angular/core';
const providerWithState = <T, S extends T>(
token: Type<T>,
stateDef: Partial<S>,
) => {
// yet to come
};
Please Note
While the second example is the one with better intellisense, we're going with the first one in this example for the sake of brevity and readability. In real applications, you should consider choosing the second way.
In the next step we need to import and use ngtx' createExtension function. This function hands us some tools and information we are going to need to describe and pass our logic to ngtx:
import { createExtension } from '@centigrade/ngtx';
const providerWithState = (token: any, stateDef: any) => {
return createExtension((getTargets, { addPredicate }, fixture) => {
// yet to come
});
};
In the code above we imported the createExtension function and declared all the helpers we're going to use soon.
Let's go through the parameters that we are provided with by createExtension:
-
getTargets: When calling this function, we get an array-like list of the targets that is defined by the user in the test:... .expect(host).to(haveStyle(...)): in this example callinggetTargetswill return us a list with only the resolvedhostreference in it. The resolved reference is of typeNgtxElementa small wrapper around Angular's built-inDebugElement. It provides some extra features, but we're not taking a close look to them right now. -
addPredicate: this helper allows you to schedule predicate-logic to the current test. Your predicate-logic will be called in the order they appear within a test and before all assertions will execute. -
fixture: this is a reference to theNgtxFixture. Again, this type is a small wrapper around Angular's built-inComponentFixture. It also adds some extra functionality and we also will ignore the details for now.
Now that we know what these helper can do for us, we can pass our predicate-logic to ngtx:
import { createExtension } from '@centigrade/ngtx';
const providerWithState = (serviceClass: any, stateDef: any) => {
return createExtension((getTargets, { addPredicate }, fixture) => {
addPredicate(() => {
// get the targets that the user defined in the test
const targets = getTargets();
// now we run our logic on all targets that were found:
for (const target of targets) {
// get the service instance from the target:
const service = target.injector.get(serviceClass);
// go through the stateDef and assign it to the service instance:
for (const propNameAndValue of Object.entries(stateDef)) {
const propName = propNameAndValue[0];
const value = propNameAndValue[1];
service[propName] = value;
}
}
// now that we updated the service on all targets, we should detect changes:
fixture.detectChanges();
});
});
};
Oh, that's quite a lot. Let's go through it to better understand what's going on:
-
first we call
addPredicateand pass it an arrow-function. This is the place where we pass ngtx our custom logic and tell that this should be a predicate. This information is important for ngtx to know when the logic should be executed (before assertions). -
next we utilize the first parameter
getTargetsto retrieve the targets that the user defined in the test. Examples:// target is "host" (= the component under test): When(host).has(providerWithState(/*...*/)); // targets are the CartItemComponents: const theCartItems = () => getAll(CartItemComponent); When(theCartItems).has(providerWithState(/*...*/)); // target is a native button element // (which would not make too much sense for this type of predicate): const theCancelButton = () => get('button.cancel'); When(theCancelButton).has(providerWithState(/*...*/));The key finding here is, that the target is always the expression that was passed to the
When-function (orand-function for subsequent, chained expressions):When(host).has(state({...})).and(theCancelButton).gets(clicked())- target 1: host (state gets set there)
- target 2: theCancelButton (emits a click-event)
-
After retrieving the target(s), we run our predicate-logic on each target. All targets provide their injector reference, that we use to inject the desired service-instance from the target.
-
In the next step we run through all properties on the given, desired service-state and assign those properties to the service.
-
in the very end, after assigning all state to the service, we run Angular's change-detection by utilizing the
fixture-parameter.
That's it! Now we can use our predicate like we drafted in the beginning:
import { providerWithState } from './my-ngtx-extensions';
import { CartService } from './services/cart.service';
// ...
it('should render all cart items', () => {
When(host)
.has(
// the predicate-extension we now did build:
providerWithState(CartService, {
cartItems: [
{ name: 'pizza', price: 5.25 },
{ name: 'ice cream', price: 2.5 },
],
}),
)
.expect(the.CartItems)
.to(beFound({ times: 2 }));
});
Custom Assertions: Expander-Example
Imagine we have a ExpanderComponent with an arrow-icon inside. Whenever the expander is opened, the arrow-icon should get a style-css property "transform: rotate(180deg)". However, if the expander is closed the transform gets reset to zero.
You created this component and it works great:
<section class="title">
<app-icon
name="arrow-down"
[style.transform]="opened ? 'rotate(180deg)' : 'rotate(0deg)'"
></app-icon>
</section>
<section *ngIf="opened" class="contents">
<ng-content></ng-content>
</section>
The Result
Clicking it will expand the expander and rotate the arrow icon:
Now we want to test the rotation behavior of the arrow. Although ngtx already offers the haveStyle-assertion, we write a similar assertion on our owns. Let's start with the vision of our extension - how do we want to use it later? What about this:
it('should rotate the arrow icon when opened', () => {
// hint: host = component under test = ExpanderComponent
When(host)
.has(state({ opened: true }))
.expect(the.ArrowIcon)
// here we use our desired extension, that we are going to build:
.to(haveStyle('transform', 'rotate(180deg)'));
});
Looks great! Now let's implement it; we start with defining the extension function's name and parameter list:
export const haveStyle = (styleProp: string, expectedValue: string) => {};
That's easy. Next we create the implementation. In order to do this we need to use ngtx' createExtension-function. This function takes a function and marks it as extension for ngtx. There is a reason for that, but let's not bother for now:
import { createExtension } from '@centigrade/ngtx';
export const haveStyle = (styleProp: string, expectedValue: string) => {
return createExtension(() => {
// yet to come ...
});
};
The createExtension-function hands us some tools and information about the current test, that we can use to inject our logic. To use them we just declare them on our argument list:
import { createExtension } from '@centigrade/ngtx';
export const haveStyle = (styleProp: string, expectedValue: string) => {
return createExtension(
(getTargets, { addAssertion, isAssertionNegated }, fixture) => {
// yet to come ...
},
);
};
Let's go through the parameters that are provided by createExtension:
-
getTargets: This is aTargetRef, meaning a function without any arguments, that returns a reference to some HTML element(s) or child component(s). So calling this function will return us the targets, we want to run ourhaveStylecheck upon. -
addAssertion: this helper allows you to schedule assertion-logic to the current test. Your assertion-logic will be called at the end of the test, after all predicates have been executed. -
isAssertionNegated: this bool-flag tells you whether the user called.notahead of your assertion, e.g.... .expect(host).not.to(haveStyle(...)). This way you can react to the negation and adapt your assertion-logic as needed. -
fixture: this is a reference to theNgtxFixture. Again, this type is a small wrapper around Angular's built-inComponentFixture. It also adds some extra functionality and we also will ignore the details for now.
Now that we know about all the arguments we are given, we can finally use them to add our assertion-logic:
import { createExtension } from '@centigrade/ngtx';
export const haveStyle = (styleProp: string, expectedValue: string) => {
return createExtension(
(getTargets, { addAssertion, isAssertionNegated }, fixture) => {
// step 1: pass ngtx our assertion function:
addAssertion(() => {
// step 2: get all targets to run our assertion-logic on:
const targets = getTargets();
// step 3: run the assertion-logic on every target:
targets.forEach((target) => {
// retrieve the HTMLElement's style object:
const styles = target.nativeElement.styles;
// check if the defined style-property has the expected value:
// "styleProp" and "expectedValue" comes from our defined parameters (line 3)
const actualValue = styles[styleProp];
expect(actualValue).toEqual(expectedValue);
});
});
},
);
};
Cool! But wait. We ignore the fact, that isAssertionNegated could be true. In this case, we need to alter our logic a bit. Let's quickly fix that:
import { createExtension } from '@centigrade/ngtx';
export const haveStyle = (styleProp: string, expectedValue: string) => {
return createExtension(
(getTargets, { addAssertion, isAssertionNegated }, fixture) => {
addAssertion(() => {
const targets = getTargets();
targets.forEach((target) => {
const styles = target.nativeElement.styles;
const actualValue = styles[styleProp];
// fix: check for isAssertionNegated and adapt logic:
if (isAssertionNegated) {
expect(actualValue).not.toEqual(expectedValue);
} else {
expect(actualValue).toEqual(expectedValue);
}
});
});
},
);
};
That's it! With this extension written, we can actually use it in our test as we drafted out earlier:
import { haveStyle } from './my-ngtx-extensions';
// ...
it('should rotate the arrow icon when opened', () => {
When(host)
.has(state({ opened: true }))
.expect(the.ArrowIcon)
.to(haveStyle('transform', 'rotate(180deg)'));
});
and we can even use it with .not:
import { haveStyle } from './my-ngtx-extensions';
// ...
it('should rotate the arrow icon when opened', () => {
When(host)
.has(state({ opened: false }))
.expect(the.ArrowIcon)
.not.to(haveStyle('transform', 'rotate(180deg)'));
});