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 global window
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's iframe
. 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 with ng.applyChanges
seems to open some doors for additional test cases.