'Test angular component @Output using Storybook and Cypress
I am trying to test the output of an angular component.
I have a checkbox component that output its value using an EventEmitter. The checkbox component is wrapped in a storybook story for demo and testing purposes:
export const basic = () => ({
moduleMetadata: {
imports: [InputCheckboxModule],
},
template: `
<div style="color: orange">
<checkbox (changeValue)="changeValue($event)" [selected]="checked" label="Awesome">
</checkbox>
</div>`,
props: {
checked: boolean('checked', true),
changeValue: action('Value Changed'),
},
});
I am using an action to capture the value change and log it to the screen.
When writing a cypress e2e for this component however, I am only using the iFrame and not the whole storybook application.
I would like to find a way to test is the output is working. I tried using a spy on the postMessage method in the iFrame but that does not work.
beforeEach(() => {
cy.visit('/iframe.html?id=inputcheckboxcomponent--basic', {
onBeforeLoad(win) {
cy.spy(window, 'postMessage').as('postMessage');
},
});
});
and then the assertions would be:
cy.get('@postMessage').should('be.called');
Is there any other way how I could assert the (changeValue)="changeValue($event)"
has fired?
Solution 1:[1]
Update 07.05.2022: Via Storybook's @storybook/addon-actions
Inspired by @jb17's answer and cypress-storybook.
/**
* my-component.component.ts
*/
@Component({
selector: 'my-component',
template: `<button (click)="outputChange.emit('test-argument')"></button>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MyComponent {
@Output()
outputChange = new EventEmitter<string>();
}
/**
* my-component.stories.ts
*/
export default {
title: 'MyComponent',
component: MyComponent,
argTypes: {
outputChange: { action: 'outputChange' },
},
} as Meta<MyComponent>;
const Template: Story<MyComponent> = (args: MyComponent) => ({
props: args,
});
export const Primary = Template.bind({});
Primary.args = {};
/**
* my-component.spec.ts
*/
describe('MyComponent @Output Test', () => {
beforeEach(() =>
cy.visit('/iframe.html?id=mycomponent--primary', {
onLoad: registerActionsAsAlias(), // ???
})
);
it('triggers output', () => {
cy.get('button').click();
// Get spy via alias set by `registerActionsAsAlias()`
cy.get('@outputChange').should('have.been.calledWith', 'test-argument');
});
});
/**
* somewhere.ts
*/
import { ActionDisplay } from '@storybook/addon-actions';
import { AddonStore } from '@storybook/addons';
export function registerActionsAsAlias(): (win: Cypress.AUTWindow) => void {
// Store spies in the returned functions' closure
const actionSpies = {};
return (win: Cypress.AUTWindow) => {
// https://github.com/storybookjs/storybook/blob/master/lib/addons/src/index.ts
const addons: AddonStore = win['__STORYBOOK_ADDONS'];
if (addons) {
// https://github.com/storybookjs/storybook/blob/master/addons/actions/src/constants.ts
addons.getChannel().addListener('storybook/actions/action-event', (event: ActionDisplay) => {
if (!actionSpies[event.data.name]) {
actionSpies[event.data.name] = cy.spy().as(event.data.name);
}
actionSpies[event.data.name](event.data.args);
});
}
};
}
Approach 1: Template
We could bind the last emitted value to the template and check it.
{
moduleMetadata: { imports: [InputCheckboxModule] },
template: `
<checkbox (changeValue)="value = $event" [selected]="checked" label="Awesome">
</checkbox>
<div id="changeValue">{{ value }}</div> <!-- ??? -->
`,
}
it("emits `changeValue`", () => {
// ...
cy.get("#changeValue").contains("true"); // ???
});
Approach 2: Window
We could assign the last emitted value to the global window
object, retrieve it in Cypress and validate the value.
export default {
title: "InputCheckbox",
component: InputCheckboxComponent,
argTypes: {
selected: { type: "boolean", defaultValue: false },
label: { type: "string", defaultValue: "Default label" },
},
} as Meta;
const Template: Story<InputCheckboxComponent> = (
args: InputCheckboxComponent
) =>
({
moduleMetadata: { imports: [InputCheckboxModule] },
component: InputCheckboxComponent,
props: args,
} as StoryFnAngularReturnType);
export const E2E = Template.bind({});
E2E.args = {
label: 'E2e label',
selected: true,
changeValue: value => (window.changeValue = value), // ???
};
it("emits `changeValue`", () => {
// ...
cy.window().its("changeValue").should("equal", true); // ???
});
Approach 3: Angular
We could use Angular's functions stored in the global namespace under ng
in order to get a reference to the Angular component and spy on the output.
?? Attention:
ng.getComponent()
is only available when Angular runs in development mode. I.e.enableProdMode()
is not called.- Set
process.env.NODE_ENV = "development";
in.storybook/main.js
to prevent Storybook to build Angular in prod mode (see source).
export const E2E = Template.bind({});
E2E.args = {
label: 'E2e label',
selected: true,
// Story stays unchanged
};
describe("InputCheckbox", () => {
beforeEach(() => {
cy.visit(
"/iframe.html?id=inputcheckboxcomponent--e-2-e",
registerComponentOutputs("checkbox") // ???
);
});
it("emits `changeValue`", () => {
// ...
cy.get("@changeValue").should("be.calledWith", true); // ???
});
});
function registerComponentOutputs(
componentSelector: string
): Partial<Cypress.VisitOptions> {
return {
// https://docs.cypress.io/api/commands/visit.html#Provide-an-onLoad-callback-function
onLoad(win) {
const componentElement: HTMLElement = win.document.querySelector(
componentSelector
);
// https://angular.io/api/core/global/ngGetComponent
const component = win.ng.getComponent(componentElement);
// Spy on all `EventEmitters` (i.e. `emit()`) and create equally named alias
Object.keys(component)
.filter(key => !!component[key].emit)
.forEach(key => cy.spy(component[key], "emit").as(key)); // ???
},
};
}
Summary
- I like in approach 1 that there is no magic. It is easy to read and understand. Unfortunately, it requires to specify a template with the additional element used to validate the output.
- Approach 2 has the advantage that we no longer need to specify a template. But we need to add for each
@Output
which we'd like to test additional code. Furthermore, it uses the globalwindow
in order to "communicate". - Apprach 3 also doesn't require a template. It has the advantage that the Storybook code (story) doesn't need any adjustments. We only need to pass a parameter to
cy.visit()
(which most likely is already used) in order to be able to perform a check. Therefore, it feels like a scalable solution if we'd like to test more components via Storybook'siframe
. Last but not least, we retrieve a reference to the Angular component. With this we would also be able to call methods or set properties directly on the component itself. This combined withng.applyChanges
seems to open some doors for additional test cases.
Solution 2:[2]
You're spying on window.postMessage()
, which is a method that enables cross-origin communication between window objects (pop-ups, pages, iframes, ...).
The iFrame in Storybook doesn't communicate any messages to another window object, but you can install Kuker or another external web debugger on your application to spy on the messages between both and thus make the Cypress spy method working.
If you choose to install Kuker on your angular application, here is how to do it:
npm install -S kuker-emitters
Add the Kuker Chrome extension as well to make it work.
Solution 3:[3]
If you are using the cypress-storybook package and the @storybook/addon-actions, there is a method which can be used for this usecase, which provides the easiest solution in my opinion.
With the Storybook-actions addon you declare your @Output events like this
export default {
title: 'Components/YourComponent',
component: YourComponent,
decorators: [
moduleMetadata({
imports: [YourModule]
})
]
} as Meta;
const Template: Story<YourStory> = (args: YourComponent) => ({
props: args
});
export const default = Template.bind({});
default.args = {
// ...
changeValue: action('Value Changed'), // action from @storybook/addon-actions
};
In your cypress test you could now call the cy.storyAction()
method and apply expect statements to it.
it('should execute event', () => {
// ...
cy.storyAction('Value Changed').should('have.been.calledWith', 'value');
})
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
Solution | Source |
---|---|
Solution 1 | |
Solution 2 | Samuel Russo |
Solution 3 | JB17 |